diff --git a/components/app/AppShell.tsx b/components/app/AppShell.tsx
index fcdf568..1a3c0e7 100644
--- a/components/app/AppShell.tsx
+++ b/components/app/AppShell.tsx
@@ -12,6 +12,8 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
+import { withAnalysisDeadline } from "@/lib/analysisDeadline";
+
import { AppStatusBar } from "@/components/app/AppStatusBar";
import {
DEFAULT_INTENT_COLOR,
@@ -61,10 +63,10 @@ const FREE_SAVE_ACK_SESSION_KEY = "motioncode_free_save_ack_session";
const FREE_SAVE_ACK_DISMISSED_KEY = "motioncode_free_save_ack_dismissed";
const STATUS_MESSAGES = [
- "Analyzing motion patterns...",
- "Reading easing curves...",
- "Detecting transform paths...",
- "Almost there...",
+ "Waiting for the analysis service...",
+ "The model is still processing your frames...",
+ "Your frames are ready; waiting for generated code...",
+ "Still waiting. You can cancel and retry.",
];
type SaveState =
@@ -419,11 +421,10 @@ export function AppShell({
setStatusMessage("Analyzing with server model...");
try {
- const analyzed = await analyzeViaApi({
- frames,
- planTier: userPlan,
- signal: controller.signal,
- });
+ const analyzed = await withAnalysisDeadline(
+ (signal) => analyzeViaApi({ frames, planTier: userPlan, signal }),
+ controller.signal,
+ );
if (operationId !== operationIdRef.current) return;
diff --git a/components/app/ProcessCanvas.tsx b/components/app/ProcessCanvas.tsx
index 1a34371..92f20b3 100644
--- a/components/app/ProcessCanvas.tsx
+++ b/components/app/ProcessCanvas.tsx
@@ -189,8 +189,8 @@ export function ProcessCanvas({
{heading}
{message}
- {currentPhase.title}
- {currentPhase.description}
+ {stage === "analyzing" ? "Model response" : currentPhase.title}
+ {stage === "analyzing" ? "Waiting for the analysis service to return code." : currentPhase.description}
@@ -205,7 +205,7 @@ export function ProcessCanvas({
{Math.round(safeProgress)}%
- {completedPhases}/{PHASES.length} phases
+ {stage === "analyzing" ? "Estimated progress" : `${completedPhases}/${PHASES.length} phases`}
@@ -214,7 +214,7 @@ export function ProcessCanvas({
style={{ "--progress": `${safeProgress}%` } as StyleVars}
/>
- {getProgressCaption(stage, safeActiveStep)}
+ {getProgressCaption(stage)}
@@ -233,9 +233,9 @@ export function ProcessCanvas({
/>
- {getFormationLabel(fieldProgress)}
+ {stage === "analyzing" ? "analysis · awaiting response" : getFormationLabel(fieldProgress)}
- {currentPhase.metric} · {getPhaseLabel(currentPhase.key, stage, safeActiveStep)}
+ {stage === "analyzing" ? "model · pending" : `${currentPhase.metric} · ${getPhaseLabel(currentPhase.key, stage, safeActiveStep)}`}
@@ -392,12 +392,12 @@ function getCurrentPhase(stage: AnalysisStage, activeStep: number) {
);
}
-function getProgressCaption(stage: AnalysisStage, activeStep: number) {
+function getProgressCaption(stage: AnalysisStage) {
switch (stage) {
case "extracting":
return "Frame buffer warming";
case "analyzing":
- return `Step ${activeStep + 1} in progress`;
+ return "Waiting for model response";
case "error":
return "Interrupted";
case "idle":
diff --git a/lib/analysisDeadline.ts b/lib/analysisDeadline.ts
new file mode 100644
index 0000000..3d05cbf
--- /dev/null
+++ b/lib/analysisDeadline.ts
@@ -0,0 +1,34 @@
+// 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.
+export const ANALYSIS_TIMEOUT_MS = 65_000;
+
+export async function withAnalysisDeadline(
+ operation: (signal: AbortSignal) => Promise,
+ signal?: AbortSignal,
+): Promise {
+ const controller = new AbortController();
+ if (signal?.aborted) {
+ throw new DOMException("Analysis cancelled.", "AbortError");
+ }
+
+ let timer: ReturnType | undefined;
+ let onAbort: (() => void) | undefined;
+ const interrupted = new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ reject(new Error("Analysis took too long. Please retry; your frames are still ready."));
+ controller.abort();
+ }, ANALYSIS_TIMEOUT_MS);
+ onAbort = () => {
+ reject(new DOMException("Analysis cancelled.", "AbortError"));
+ controller.abort();
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+
+ try {
+ return await Promise.race([operation(controller.signal), interrupted]);
+ } finally {
+ clearTimeout(timer);
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
+ }
+}
diff --git a/lib/server/gemini.ts b/lib/server/gemini.ts
index 17839ce..3645aaf 100644
--- a/lib/server/gemini.ts
+++ b/lib/server/gemini.ts
@@ -203,11 +203,12 @@ export async function analyzeFramesWithGemini(
): Promise {
const apiKey = deps.apiKey ?? getServerEnv().geminiApiKey;
const fetcher = deps.fetch ?? fetch;
+ const deadline = Date.now() + 50_000;
const primary = resolveUpstreamGeminiModel(input.model);
const fallback = resolveUpstreamGeminiModel("gemini-2.5-flash");
try {
- return await requestGeminiAnalysis(input, primary, apiKey, fetcher);
+ return await requestGeminiWithRetries(input, primary, apiKey, fetcher, deadline);
} catch (error) {
// Pro models are often unavailable to a key (preview access, free-tier
// quota); answer with Flash rather than failing the paid analysis.
@@ -220,15 +221,60 @@ export async function analyzeFramesWithGemini(
}
serverLogger.warn("gemini.model_fallback", { fallback, model: primary });
- return requestGeminiAnalysis(input, fallback, apiKey, fetcher);
+ return requestGeminiWithRetries(input, fallback, apiKey, fetcher, deadline);
}
}
+const RETRYABLE_STATUSES = new Set([500, 502, 503, 504]);
+class RetryableGeminiError extends ApiError {}
+
+const TEMPORARY_FAILURE_MESSAGE =
+ "Analysis service is temporarily busy. Please try again shortly.";
+
+async function requestGeminiWithRetries(
+ input: GeminiAnalyzeInput,
+ model: string,
+ apiKey: string,
+ fetcher: typeof fetch,
+ deadline: number,
+): Promise {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) {
+ break;
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), Math.min(15_000, remainingMs));
+ try {
+ return await requestGeminiAnalysis(input, model, apiKey, fetcher, controller.signal);
+ } catch (error) {
+ const retryable = controller.signal.aborted || error instanceof TypeError ||
+ error instanceof RetryableGeminiError;
+ if (!retryable) {
+ throw error;
+ }
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ const delayMs = 1_000 * 2 ** attempt + Math.floor(Math.random() * 250);
+ if (attempt === 2 || Date.now() + delayMs >= deadline) {
+ break;
+ }
+ serverLogger.warn("gemini.request_retry", { attempt: attempt + 1, delayMs, model });
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+
+ throw new ApiError("MODEL_FAILED", TEMPORARY_FAILURE_MESSAGE, 503);
+}
+
async function requestGeminiAnalysis(
input: GeminiAnalyzeInput,
upstreamModel: string,
apiKey: string,
fetcher: typeof fetch,
+ signal: AbortSignal,
): Promise {
const response = await fetcher(
`${GEMINI_ENDPOINT_ROOT}/${upstreamModel}:generateContent`,
@@ -257,6 +303,7 @@ async function requestGeminiAnalysis(
"x-goog-api-key": apiKey,
},
method: "POST",
+ signal,
},
);
@@ -276,6 +323,9 @@ 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.");
}
diff --git a/tests/unit/analysis-deadline.test.ts b/tests/unit/analysis-deadline.test.ts
new file mode 100644
index 0000000..acb8291
--- /dev/null
+++ b/tests/unit/analysis-deadline.test.ts
@@ -0,0 +1,46 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { ANALYSIS_TIMEOUT_MS, withAnalysisDeadline } from "@/lib/analysisDeadline";
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+
+it("ends a stalled operation and aborts the underlying request", async () => {
+ let requestSignal: AbortSignal | undefined;
+ const result = withAnalysisDeadline((signal) => {
+ requestSignal = signal;
+ return new Promise(() => {});
+ });
+ const assertion = expect(result).rejects.toThrow("Analysis took too long");
+ await vi.advanceTimersByTimeAsync(ANALYSIS_TIMEOUT_MS);
+ await assertion;
+ expect(requestSignal?.aborted).toBe(true);
+ expect(vi.getTimerCount()).toBe(0);
+});
+
+it("preserves explicit cancellation even if the operation never settles", async () => {
+ const controller = new AbortController();
+ const result = withAnalysisDeadline(() => new Promise(() => {}), controller.signal);
+ const assertion = expect(result).rejects.toMatchObject({ name: "AbortError" });
+ controller.abort();
+ await assertion;
+ expect(vi.getTimerCount()).toBe(0);
+});
+
+it("does not start an already-cancelled operation", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const operation = vi.fn();
+ await expect(withAnalysisDeadline(operation, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
+ expect(operation).not.toHaveBeenCalled();
+});
+
+it("cleans up the deadline after successful analysis", async () => {
+ await expect(withAnalysisDeadline(async () => "code")).resolves.toBe("code");
+ expect(vi.getTimerCount()).toBe(0);
+});
+
+it("preserves server errors and cleans up the deadline", async () => {
+ await expect(withAnalysisDeadline(async () => { throw new Error("Model busy"); })).rejects.toThrow("Model busy");
+ expect(vi.getTimerCount()).toBe(0);
+});
diff --git a/tests/unit/gemini-retries.test.ts b/tests/unit/gemini-retries.test.ts
new file mode 100644
index 0000000..1ffed93
--- /dev/null
+++ b/tests/unit/gemini-retries.test.ts
@@ -0,0 +1,86 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { analyzeFramesWithGemini } from "@/lib/server/gemini";
+
+const input = { frames: ["jpeg"], model: "gemini-2.5-flash" as const };
+const success = () => Response.json({
+ candidates: [{ content: { parts: [{ text: JSON.stringify({ intent: "entrance" }) }] } }],
+});
+const busy = () => Response.json({ error: { message: "high demand" } }, { status: 503 });
+const run = (fetcher: typeof fetch, model = input.model as "gemini-2.5-flash" | "gemini-2.5-pro") =>
+ analyzeFramesWithGemini({ ...input, model }, { apiKey: "test-key", fetch: fetcher });
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubEnv("GEMINI_FLASH_MODEL", "test-flash");
+ vi.stubEnv("GEMINI_PRO_MODEL", "test-pro");
+});
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllEnvs();
+});
+
+describe("Gemini transient failures", () => {
+ it("recovers from an overloaded model with delayed retries", async () => {
+ const fetcher = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy())
+ .mockImplementation(async () => success());
+ const result = run(fetcher);
+ const assertion = expect(result).resolves.toMatchObject({ spec: { intent: "entrance" } });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ await vi.runAllTimersAsync();
+ await assertion;
+ expect(fetcher).toHaveBeenCalledTimes(3);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("stops after three failures 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);
+ });
+
+ it.each([400, 401, 403, 404, 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("retries a failed network connection", async () => {
+ const fetcher = vi.fn().mockRejectedValueOnce(new TypeError("fetch failed"))
+ .mockImplementation(async () => success());
+ const assertion = expect(run(fetcher)).resolves.toHaveProperty("spec");
+ await vi.runAllTimersAsync();
+ await assertion;
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ });
+
+ it("aborts stalled requests and bounds primary plus fallback to 50 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 });
+ }));
+ 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(vi.getTimerCount()).toBe(0);
+ });
+
+ it("retains the Pro-to-Flash fallback after temporary failures", async () => {
+ const fetcher = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy())
+ .mockResolvedValueOnce(busy()).mockImplementation(async () => success());
+ const assertion = expect(run(fetcher, "gemini-2.5-pro")).resolves.toHaveProperty("spec");
+ await vi.runAllTimersAsync();
+ await assertion;
+ expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
+ ...Array(3).fill(expect.stringContaining("/test-pro:")), expect.stringContaining("/test-flash:"),
+ ]);
+ });
+});
diff --git a/tests/unit/process-canvas-waiting.test.tsx b/tests/unit/process-canvas-waiting.test.tsx
new file mode 100644
index 0000000..555ce1d
--- /dev/null
+++ b/tests/unit/process-canvas-waiting.test.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { expect, it, vi } from "vitest";
+
+vi.mock("next/dynamic", () => ({ default: () => () => null }));
+import { ProcessCanvas } from "@/components/app/ProcessCanvas";
+
+it("labels estimated progress as waiting rather than completed compilation", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Estimated progress");
+ expect(html).toContain("Waiting for model response");
+ expect(html).toContain("analysis · awaiting response");
+ expect(html).not.toContain("compiled · stable");
+ expect(html).not.toContain("6/6 phases");
+});