diff --git a/.env.local.example b/.env.local.example index 1fbcac5..e7619f3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -29,8 +29,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= GEMINI_API_KEY= # Optional upstream Gemini models behind the Flash / Pro plan tiers. Defaults: -# gemini-3.8-flash and gemini-3.1-pro-preview (Pro falls back to Flash on error). +# gemini-3.5-flash-lite and gemini-3.1-pro-preview (Pro falls back to Flash-Lite). GEMINI_FLASH_MODEL= +# Backup lightweight model for transient Flash-Lite failures. +GEMINI_FLASH_FALLBACK_MODEL= GEMINI_PRO_MODEL= OPENAI_API_KEY= RAZORPAY_KEY_ID= diff --git a/components/app/AppShell.tsx b/components/app/AppShell.tsx index 1a3c0e7..4d4678f 100644 --- a/components/app/AppShell.tsx +++ b/components/app/AppShell.tsx @@ -424,6 +424,7 @@ export function AppShell({ const analyzed = await withAnalysisDeadline( (signal) => analyzeViaApi({ frames, planTier: userPlan, signal }), controller.signal, + userPlan === "free" ? 90_000 : 105_000, ); if (operationId !== operationIdRef.current) return; diff --git a/lib/analysisDeadline.ts b/lib/analysisDeadline.ts index 3d05cbf..9fe8298 100644 --- a/lib/analysisDeadline.ts +++ b/lib/analysisDeadline.ts @@ -1,10 +1,11 @@ // The browser deadline includes frame preparation, the API call, and body reads. -// Leave time beyond the server's 50-second model budget for auth and transport. +// Leave time beyond each plan's server model budget for auth and transport. export const ANALYSIS_TIMEOUT_MS = 65_000; export async function withAnalysisDeadline( operation: (signal: AbortSignal) => Promise, signal?: AbortSignal, + timeoutMs = ANALYSIS_TIMEOUT_MS, ): Promise { const controller = new AbortController(); if (signal?.aborted) { @@ -17,7 +18,7 @@ export async function withAnalysisDeadline( timer = setTimeout(() => { reject(new Error("Analysis took too long. Please retry; your frames are still ready.")); controller.abort(); - }, ANALYSIS_TIMEOUT_MS); + }, timeoutMs); onAbort = () => { reject(new DOMException("Analysis cancelled.", "AbortError")); controller.abort(); diff --git a/lib/server/gemini.ts b/lib/server/gemini.ts index 3645aaf..e3d25f7 100644 --- a/lib/server/gemini.ts +++ b/lib/server/gemini.ts @@ -11,6 +11,7 @@ import { type OutputFramework, } from "@/lib/contracts/motion"; +import { MOTION_ANALYSIS_SCHEMA } from "./motionAnalysisSchema"; import { ApiError } from "./apiErrors"; import { getServerEnv } from "./env"; import { serverLogger } from "./logger"; @@ -26,7 +27,7 @@ export const GEMINI_MODELS = ["gemini-2.5-flash", "gemini-2.5-pro"] as const; // stay put while GEMINI_FLASH_MODEL / GEMINI_PRO_MODEL can swap the upstream // model without code changes. const DEFAULT_UPSTREAM_MODELS: Record = { - "gemini-2.5-flash": "gemini-3.8-flash", + "gemini-2.5-flash": "gemini-3.5-flash-lite", "gemini-2.5-pro": "gemini-3.1-pro-preview", }; @@ -171,6 +172,7 @@ Use this exact shape: } Code requirements (each snippet must run on its own in a live preview): +- Keep each snippet concise: at most 40 lines, with no external media or long embedded data. - Target the animated element with the class "element" in every snippet. - css: complete rules plus @keyframes; bind the animation (or a transition with its :hover/state rule) to .element. - gsap: ES module that imports gsap from "gsap" and animates ".element" at the top level. @@ -203,16 +205,23 @@ export async function analyzeFramesWithGemini( ): Promise { const apiKey = deps.apiKey ?? getServerEnv().geminiApiKey; const fetcher = deps.fetch ?? fetch; - const deadline = Date.now() + 50_000; + const deadline = Date.now() + (input.model === "gemini-2.5-pro" ? 90_000 : 75_000); const primary = resolveUpstreamGeminiModel(input.model); - const fallback = resolveUpstreamGeminiModel("gemini-2.5-flash"); + const flash = resolveUpstreamGeminiModel("gemini-2.5-flash"); + const fallback = primary === flash + ? process.env.GEMINI_FLASH_FALLBACK_MODEL?.trim() || "gemini-3.1-flash-lite" + : flash; try { - return await requestGeminiWithRetries(input, primary, apiKey, fetcher, deadline); + // Reserve time for a different model rather than spending the entire + // request budget retrying an overloaded primary. + const primaryDeadline = Math.min(deadline, Date.now() + (input.model === "gemini-2.5-pro" ? 60_000 : 45_000)); + return await requestGeminiWithRetries(input, primary, apiKey, fetcher, primaryDeadline); } catch (error) { // Pro models are often unavailable to a key (preview access, free-tier // quota); answer with Flash rather than failing the paid analysis. - if (primary === fallback || !(error instanceof ApiError)) { + if (primary === fallback || !(error instanceof ApiError) || + (input.model === "gemini-2.5-flash" && error.status !== 503 && error.status !== 404)) { throw error; } @@ -245,7 +254,7 @@ async function requestGeminiWithRetries( } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), Math.min(15_000, remainingMs)); + const timeout = setTimeout(() => controller.abort(), Math.min(30_000, remainingMs)); try { return await requestGeminiAnalysis(input, model, apiKey, fetcher, controller.signal); } catch (error) { @@ -295,6 +304,8 @@ async function requestGeminiAnalysis( ], generationConfig: { maxOutputTokens: 8192, + responseMimeType: "application/json", + responseJsonSchema: MOTION_ANALYSIS_SCHEMA, temperature: 0.1, }, }), @@ -326,7 +337,7 @@ async function requestGeminiAnalysis( if (RETRYABLE_STATUSES.has(response.status)) { throw new RetryableGeminiError("MODEL_FAILED", TEMPORARY_FAILURE_MESSAGE, response.status); } - throw new ApiError("MODEL_FAILED", "Gemini analysis failed."); + throw new ApiError("MODEL_FAILED", "Gemini analysis failed.", response.status === 404 ? 404 : 502); } const payload = (await response.json()) as unknown; diff --git a/lib/server/motionAnalysisSchema.ts b/lib/server/motionAnalysisSchema.ts new file mode 100644 index 0000000..c899674 --- /dev/null +++ b/lib/server/motionAnalysisSchema.ts @@ -0,0 +1,55 @@ +export const MOTION_ANALYSIS_SCHEMA = { + additionalProperties: false, + properties: { + accessibility_note: { type: "string" }, + css: { type: "string" }, + delay_ms: { type: "integer" }, + description: { type: "string" }, + duration_ms: { type: "integer" }, + easing: { type: "string" }, + element: { type: "string" }, + framer_motion: { type: "string" }, + gpu_accelerated: { type: "boolean" }, + gsap: { type: "string" }, + implementation_notes: { + items: { type: "string" }, + type: "array", + }, + intent: { + enum: [ + "entrance", + "exit", + "hover", + "loading", + "loop", + "morph", + "scroll", + "unknown", + ], + type: "string", + }, + keyframes_detected: { type: "integer" }, + loops: { type: "boolean" }, + performance_score: { type: "number" }, + react_spring: { type: "string" }, + }, + required: [ + "intent", + "element", + "duration_ms", + "delay_ms", + "easing", + "loops", + "description", + "keyframes_detected", + "performance_score", + "gpu_accelerated", + "accessibility_note", + "implementation_notes", + "css", + "gsap", + "framer_motion", + "react_spring", + ], + type: "object", +} as const; diff --git a/lib/server/openai.ts b/lib/server/openai.ts index cb33976..b809750 100644 --- a/lib/server/openai.ts +++ b/lib/server/openai.ts @@ -1,3 +1,4 @@ +import { MOTION_ANALYSIS_SCHEMA } from "./motionAnalysisSchema"; import { ApiError } from "./apiErrors"; import { buildGeminiPrompt, @@ -52,7 +53,7 @@ export async function analyzeFramesWithOpenAI( text: { format: { name: "motion_analysis", - schema: OPENAI_MOTION_ANALYSIS_SCHEMA, + schema: MOTION_ANALYSIS_SCHEMA, strict: true, type: "json_schema", }, @@ -75,61 +76,7 @@ export async function analyzeFramesWithOpenAI( return normalizeGeminiGeneratedAnalysis(parsed); } -const OPENAI_MOTION_ANALYSIS_SCHEMA = { - additionalProperties: false, - properties: { - accessibility_note: { type: "string" }, - css: { type: "string" }, - delay_ms: { type: "integer" }, - description: { type: "string" }, - duration_ms: { type: "integer" }, - easing: { type: "string" }, - element: { type: "string" }, - framer_motion: { type: "string" }, - gpu_accelerated: { type: "boolean" }, - gsap: { type: "string" }, - implementation_notes: { - items: { type: "string" }, - type: "array", - }, - intent: { - enum: [ - "entrance", - "exit", - "hover", - "loading", - "loop", - "morph", - "scroll", - "unknown", - ], - type: "string", - }, - keyframes_detected: { type: "integer" }, - loops: { type: "boolean" }, - performance_score: { type: "number" }, - react_spring: { type: "string" }, - }, - required: [ - "intent", - "element", - "duration_ms", - "delay_ms", - "easing", - "loops", - "description", - "keyframes_detected", - "performance_score", - "gpu_accelerated", - "accessibility_note", - "implementation_notes", - "css", - "gsap", - "framer_motion", - "react_spring", - ], - type: "object", -} as const; + function extractOpenAIText(payload: unknown) { const directText = getRecord(payload).output_text; diff --git a/package.json b/package.json index 0aad5ed..e35277a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest", "test:e2e": "playwright test", "vendor:preview": "node scripts/build-preview-vendor.mjs", - "check": "npm run typecheck && npm run lint && npm test && npm run build" + "check": "npm run typecheck && npm run lint && npm test && npm run build", + "postbuild": "node scripts/verify-analysis-build.mjs" }, "dependencies": { "@base-ui/react": "^1.2.0", diff --git a/scripts/verify-analysis-build.mjs b/scripts/verify-analysis-build.mjs new file mode 100644 index 0000000..7d60911 --- /dev/null +++ b/scripts/verify-analysis-build.mjs @@ -0,0 +1,16 @@ +import { spawnSync } from "node:child_process"; + +// Opt-in release check. Uses the deployment environment's own provider key; +// no credentials are exported and the test adapters do not write user data. +if (process.env.MOTIONCODE_VERIFY_ANALYSIS_ON_BUILD === "true") { + if (!process.env.GEMINI_API_KEY) throw new Error("Missing Gemini key for release verification."); + const result = spawnSync(process.execPath, [ + "node_modules/vitest/vitest.mjs", "run", "tests/integration/live-analysis.test.ts", "--reporter=verbose", + ], { + stdio: "inherit", + env: { ...process.env, MOTIONCODE_LIVE_ANALYSIS: "1" }, + timeout: 360_000, + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} diff --git a/tests/e2e/app-performance.spec.ts b/tests/e2e/app-performance.spec.ts index 183f082..329f73e 100644 --- a/tests/e2e/app-performance.spec.ts +++ b/tests/e2e/app-performance.spec.ts @@ -163,7 +163,7 @@ test.describe("application performance baseline", () => { ); }); - test("pauses the particle RAF while hidden or outside the viewport", async ({ page }) => { + test("pauses particle rendering while hidden or outside the viewport", async ({ page }) => { await mockSupabaseBrowserRequests(page); await page.addInitScript(() => { const NativeIntersectionObserver = window.IntersectionObserver; @@ -202,12 +202,24 @@ test.describe("application performance baseline", () => { await page.evaluate(() => { let count = 0; let lastAt = performance.now(); - const nativeRequestAnimationFrame = window.requestAnimationFrame.bind(window); - window.requestAnimationFrame = (callback) => { - count += 1; - lastAt = performance.now(); - return nativeRequestAnimationFrame(callback); - }; + // Count this field's actual draw calls. Global requestAnimationFrame also + // includes navigation/scroll animations, which can continue offscreen. + const prototypes = [WebGLRenderingContext.prototype]; + if (typeof WebGL2RenderingContext !== "undefined") { + prototypes.push(WebGL2RenderingContext.prototype); + } + for (const prototype of prototypes) { + for (const method of ["drawArrays", "drawElements"] as const) { + const native = prototype[method]; + Object.defineProperty(prototype, method, { configurable: true, writable: true, value: function (this: WebGLRenderingContext, ...args: number[]) { + if (this.canvas instanceof HTMLCanvasElement && this.canvas.closest('[data-testid="process-field"]')) { + count += 1; + lastAt = performance.now(); + } + return Reflect.apply(native, this, args); + } }); + } + } Object.defineProperty(window, "__motioncodeRafCount", { configurable: true, get: () => count, diff --git a/tests/fixtures/motion-contact-sheet.jpg b/tests/fixtures/motion-contact-sheet.jpg new file mode 100644 index 0000000..57cb605 Binary files /dev/null and b/tests/fixtures/motion-contact-sheet.jpg differ diff --git a/tests/integration/live-analysis.test.ts b/tests/integration/live-analysis.test.ts new file mode 100644 index 0000000..5ba870b --- /dev/null +++ b/tests/integration/live-analysis.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment node +// Opt in with MOTIONCODE_LIVE_ANALYSIS=1 and a development GEMINI_API_KEY. +// Exercises the real provider and route contract; auth/quota adapters are isolated. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { handleAnalyzeRequest } from "@/app/api/analyze/handler"; +import { PLAN_ENTITLEMENTS, PLAN_TIERS } from "@/lib/contracts/plans"; +import { analyzeFramesWithGemini } from "@/lib/server/gemini"; +import { createAnalyzeAbuseGuard } from "@/lib/server/abuse"; + +const enabled = process.env.MOTIONCODE_LIVE_ANALYSIS === "1"; +const frame = readFileSync(fileURLToPath(new URL("../fixtures/motion-contact-sheet.jpg", import.meta.url))).toString("base64"); + +for (const planTier of PLAN_TIERS) { + test.skipIf(!enabled)(`live ${planTier} analysis returns usable code`, async () => { + let reservations = 0; + const started = Date.now(); + const model = planTier === "free" ? "gemini-2.5-flash" : "gemini-2.5-pro"; + const response = await handleAnalyzeRequest(new Request("https://motioncode.test/api/analyze", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ frames: [frame], frameGrid: { columns: 4, rows: 1, frameCount: 4 }, model }), + }), { + generateAnalysis: (input) => analyzeFramesWithGemini(input, { apiKey: process.env.GEMINI_API_KEY }), + getCurrentUser: async () => ({ id: `live-test-${planTier}` }), + getEntitlementSummary: async () => ({ planTier, entitlements: PLAN_ENTITLEMENTS[planTier] }), + isOpenAiAnalysisEnabled: () => false, + abuseGuard: createAnalyzeAbuseGuard(), + reserveDailyAnalysisUsage: async () => { reservations += 1; return true; }, + releaseDailyAnalysisUsage: async () => { reservations -= 1; return true; }, + usage: { record: async () => {} }, + audit: { record: async () => {} }, + }); + const json = await response.json(); + console.log(JSON.stringify({ planTier, status: response.status, durationMs: Date.now() - started, code: json.code, outputs: json.data?.outputs?.map((entry: { framework: string }) => entry.framework) })); + expect(response.status, JSON.stringify(json)).toBe(200); + expect(json.ok).toBe(true); + expect(json.data.model).toBe(model); + expect(json.data.frameCount).toBe(4); + expect(reservations).toBe(1); + for (const framework of ["css", "gsap", "framer-motion", "react-spring"]) { + const output = json.data.outputs.find((entry: { framework: string }) => entry.framework === framework); + expect(output?.code.trim().length, framework).toBeGreaterThan(20); + } + }, 120_000); +} diff --git a/tests/unit/analyze-api.test.ts b/tests/unit/analyze-api.test.ts index 03dd202..ed8fef6 100644 --- a/tests/unit/analyze-api.test.ts +++ b/tests/unit/analyze-api.test.ts @@ -707,6 +707,40 @@ describe("POST /api/analyze", () => { expect(deps.generateOpenAiAnalysis).not.toHaveBeenCalled(); }); + it.each(["free", "pro", "studio"] as const)("accepts %s frame limits and rejects excess frames", async (planTier) => { + const { handleAnalyzeRequest } = await import("@/app/api/analyze/handler"); + const deps = createDeps({ planTier }); + const maxFrames = PLAN_ENTITLEMENTS[planTier].maxFramesPerAnalysis; + const model = planTier === "free" ? "gemini-2.5-flash" : "gemini-2.5-pro"; + const body = requestBody({ model, frameGrid: { columns: 4, rows: 4, frameCount: maxFrames } }); + const response = await handleAnalyzeRequest(makeRequest(body), { ...deps, isOpenAiAnalysisEnabled: () => false }); + expect(response.status).toBe(200); + expect((await response.json()).data.frameCount).toBe(maxFrames); + const rejected = await handleAnalyzeRequest(makeRequest({ ...body, frameGrid: { columns: 5, rows: 4, frameCount: maxFrames + 1 } }), deps); + expect(rejected.status).toBe(planTier === "studio" ? 400 : 429); + expect((await rejected.json()).code).toBe(planTier === "studio" ? "INVALID_REQUEST" : "QUOTA_EXCEEDED"); + expect(deps.generateAnalysis).toHaveBeenCalledTimes(1); + }); + + it("lets Free retry after a provider failure and counts only the successful analysis", async () => { + const { handleAnalyzeRequest } = await import("@/app/api/analyze/handler"); + const deps = createDeps({ planTier: "free" }); + let used = 0; + deps.reserveDailyAnalysisUsage.mockImplementation(async () => { + if (used >= 1) return false; + used += 1; + return true; + }); + deps.releaseDailyAnalysisUsage.mockImplementation(async () => { used -= 1; return true; }); + deps.generateAnalysis.mockRejectedValueOnce(new ApiError("MODEL_FAILED", "Busy", 503)); + expect((await handleAnalyzeRequest(makeRequest(requestBody()), deps)).status).toBe(503); + expect(used).toBe(0); + expect((await handleAnalyzeRequest(makeRequest(requestBody()), deps)).status).toBe(200); + expect(used).toBe(1); + expect((await handleAnalyzeRequest(makeRequest(requestBody()), deps)).status).toBe(429); + expect(deps.generateAnalysis).toHaveBeenCalledTimes(2); + }); + it("records usage and audit events with the authorized project workspace when the client omits it", async () => { const { handleAnalyzeRequest } = await import("@/app/api/analyze/handler"); const deps = createDeps(); @@ -1360,7 +1394,7 @@ describe("Gemini upstream models", () => { ); expect(fetcher).toHaveBeenCalledWith( - expect.stringContaining("/gemini-3.8-flash:generateContent"), + expect.stringContaining("/gemini-3.5-flash-lite:generateContent"), expect.anything(), ); }); @@ -1395,7 +1429,7 @@ describe("Gemini upstream models", () => { expect(result.spec.intent).toBe("entrance"); expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ expect.stringContaining("/gemini-3.1-pro-preview:"), - expect.stringContaining("/gemini-3.8-flash:"), + expect.stringContaining("/gemini-3.5-flash-lite:"), ]); }); diff --git a/tests/unit/gemini-retries.test.ts b/tests/unit/gemini-retries.test.ts index 1ffed93..2d4ede1 100644 --- a/tests/unit/gemini-retries.test.ts +++ b/tests/unit/gemini-retries.test.ts @@ -14,6 +14,7 @@ beforeEach(() => { vi.useFakeTimers(); vi.stubEnv("GEMINI_FLASH_MODEL", "test-flash"); vi.stubEnv("GEMINI_PRO_MODEL", "test-pro"); + vi.stubEnv("GEMINI_FLASH_FALLBACK_MODEL", "test-lite-backup"); }); afterEach(() => { vi.useRealTimers(); @@ -34,23 +35,32 @@ describe("Gemini transient failures", () => { expect(vi.getTimerCount()).toBe(0); }); - it("stops after three failures and returns a temporary-unavailability error", async () => { + it("stops after bounded failures across both lightweight models and returns a temporary-unavailability error", async () => { const fetcher = vi.fn(async () => busy()); const assertion = expect(run(fetcher)).rejects.toMatchObject({ code: "MODEL_FAILED", status: 503, message: expect.stringContaining("temporarily busy"), }); await vi.runAllTimersAsync(); await assertion; - expect(fetcher).toHaveBeenCalledTimes(3); + expect(fetcher).toHaveBeenCalledTimes(6); }); - it.each([400, 401, 403, 404, 429])("does not retry HTTP %s for Flash", async (status) => { + it.each([400, 401, 403, 429])("does not retry HTTP %s for Flash", async (status) => { const fetcher = vi.fn(async () => Response.json({ error: { message: "rejected" } }, { status })); await expect(run(fetcher)).rejects.toMatchObject({ code: status === 429 ? "QUOTA_EXCEEDED" : "MODEL_FAILED" }); expect(fetcher).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); + it("uses a compatible lightweight backup when the primary model is retired", async () => { + const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ error: { message: "retired" } }, { status: 404 })) + .mockImplementation(async () => success()); + await expect(run(fetcher)).resolves.toHaveProperty("spec"); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + expect.stringContaining("/test-flash:"), expect.stringContaining("/test-lite-backup:"), + ]); + }); + it("retries a failed network connection", async () => { const fetcher = vi.fn().mockRejectedValueOnce(new TypeError("fetch failed")) .mockImplementation(async () => success()); @@ -60,7 +70,7 @@ describe("Gemini transient failures", () => { expect(fetcher).toHaveBeenCalledTimes(2); }); - it("aborts stalled requests and bounds primary plus fallback to 50 seconds", async () => { + it("aborts stalled requests and bounds primary plus fallback to 90 seconds", async () => { const started = Date.now(); const fetcher = vi.fn((_url: unknown, options?: RequestInit) => new Promise((_resolve, reject) => { options?.signal?.addEventListener("abort", () => reject(new DOMException("Timed out", "AbortError")), { once: true }); @@ -68,8 +78,8 @@ describe("Gemini transient failures", () => { const assertion = expect(run(fetcher, "gemini-2.5-pro")).rejects.toMatchObject({ code: "MODEL_FAILED", status: 503 }); await vi.runAllTimersAsync(); await assertion; - expect(Date.now() - started).toBeLessThanOrEqual(50_000); - expect(fetcher).toHaveBeenCalledTimes(4); + expect(Date.now() - started).toBeLessThanOrEqual(90_000); + expect(fetcher).toHaveBeenCalledTimes(3); expect(vi.getTimerCount()).toBe(0); }); diff --git a/vercel.json b/vercel.json index 8e383f7..d1dfa37 100644 --- a/vercel.json +++ b/vercel.json @@ -28,5 +28,6 @@ } ] } - ] + ], + "buildCommand": "npm run build" }