Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions components/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions lib/analysisDeadline.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
operation: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal,
timeoutMs = ANALYSIS_TIMEOUT_MS,
): Promise<T> {
const controller = new AbortController();
if (signal?.aborted) {
Expand All @@ -17,7 +18,7 @@ export async function withAnalysisDeadline<T>(
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();
Expand Down
25 changes: 18 additions & 7 deletions lib/server/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<GeminiModel, string> = {
"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",
};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -203,16 +205,23 @@ export async function analyzeFramesWithGemini(
): Promise<GeminiAnalysis> {
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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -295,6 +304,8 @@ async function requestGeminiAnalysis(
],
generationConfig: {
maxOutputTokens: 8192,
responseMimeType: "application/json",
responseJsonSchema: MOTION_ANALYSIS_SCHEMA,
temperature: 0.1,
},
}),
Expand Down Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions lib/server/motionAnalysisSchema.ts
Original file line number Diff line number Diff line change
@@ -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;
59 changes: 3 additions & 56 deletions lib/server/openai.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MOTION_ANALYSIS_SCHEMA } from "./motionAnalysisSchema";
import { ApiError } from "./apiErrors";
import {
buildGeminiPrompt,
Expand Down Expand Up @@ -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",
},
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions scripts/verify-analysis-build.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 19 additions & 7 deletions tests/e2e/app-performance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Binary file added tests/fixtures/motion-contact-sheet.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions tests/integration/live-analysis.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading