Skip to content

Commit 4e0034f

Browse files
fix(studio): fix capture button silent failures and broken CLI seek (#904)
* fix(studio): fix capture button silent failures and broken CLI seek The Capture button could silently fail with no user feedback due to several compounding issues: - The click handler's try-catch only covered the fetch call, leaving waitForPendingDomEditSaves() and URL construction unprotected. Any error there became an unhandled promise rejection with zero UI feedback. Wrap the entire handler body in try-catch. - No timeout on the fetch or save-queue drain, so a hung server or stuck save queue caused the button to appear permanently broken. Add a 30s AbortController timeout on the fetch and a 5s race timeout on waitForPendingDomEditSaves. - The CLI server's thumbnail seek used `__timeline` (singular) which doesn't exist — the runtime registers `__timelines` (plural). Also used `.seek()` instead of `.pause(t)` and didn't kick the GSAP ticker. Align with the Vite adapter's working seek logic. - The CLI server's getThumbnailBrowser and generateThumbnail catch blocks swallowed all errors silently — Chrome launch failures and screenshot errors were invisible. Add console.warn logging. - Parse the JSON error body from the server so the toast shows the actual message ("Chrome browser may not be available") instead of just "Capture failed (500)". Closes #902 * fix(cli): apply same seek fix to snapshot command, address review nits - Fix snapshot.ts seek logic: same __timeline→__timelines + .pause(t) + gsap ticker kick fix as studioServer.ts (caught by Vai's review) - Use typed Window shape in waitForFunction instead of (window as any) - Use function-form page.evaluate for document.fonts?.ready * fix(cli): force screenshot mode for thumbnail browser on Linux Root cause: on Linux, acquireBrowser defaults to beginframe mode (--enable-begin-frame-control) which makes page.screenshot() hang indefinitely — beginframe mode expects CDP HeadlessExperimental.beginFrame commands, not Puppeteer's Page.captureScreenshot. Pass forceScreenshot: true and captureMode: "screenshot" so the thumbnail browser always uses screenshot-compatible Chrome flags. Reproduced on Linux devbox: thumbnail endpoint hung >30s with beginframe flags; returns a valid PNG instantly in screenshot mode.
1 parent 3569c48 commit 4e0034f

4 files changed

Lines changed: 101 additions & 53 deletions

File tree

packages/cli/src/commands/snapshot.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -237,19 +237,18 @@ async function captureSnapshots(
237237
const time = positions[i]!;
238238

239239
await page.evaluate((t: number) => {
240-
const win = window as any;
241-
if (win.__player?.seek) {
242-
win.__player.seek(t);
243-
} else {
244-
const tls = win.__timelines;
245-
if (tls) {
246-
for (const key in tls) {
247-
if (tls[key]?.seek) {
248-
tls[key].pause();
249-
tls[key].seek(t);
250-
}
251-
}
240+
const w = window as Window & {
241+
__player?: { seek?: (time: number) => void };
242+
__timelines?: Record<string, { pause?: (time?: number) => void }>;
243+
gsap?: { ticker?: { tick?: () => void } };
244+
};
245+
if (typeof w.__player?.seek === "function") {
246+
w.__player.seek(t);
247+
} else if (w.__timelines) {
248+
for (const tl of Object.values(w.__timelines)) {
249+
tl?.pause?.(t);
252250
}
251+
w.gsap?.ticker?.tick?.();
253252
}
254253
}, time);
255254

packages/cli/src/server/studioServer.ts

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
138138
/* continue — acquireBrowser will try its own resolution */
139139
}
140140

141-
const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }));
141+
const acquired = await acquireBrowser(
142+
buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
143+
{ forceScreenshot: true },
144+
);
142145
_thumbnailBrowser = acquired.browser;
143146
_thumbnailBrowser.on("disconnected", () => {
144147
_thumbnailBrowser = null;
@@ -155,7 +158,11 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
155158
process.once("SIGTERM", () => void onExit());
156159
process.once("SIGINT", () => void onExit());
157160
return _thumbnailBrowser;
158-
} catch {
161+
} catch (err) {
162+
console.warn(
163+
"[Studio] Failed to launch thumbnail browser:",
164+
err instanceof Error ? err.message : err,
165+
);
159166
_thumbnailBrowserInitializing = null;
160167
return null;
161168
}
@@ -301,35 +308,45 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
301308
},
302309

303310
async generateThumbnail(opts): Promise<Buffer | null> {
304-
// Reuse a single browser across all thumbnail requests for this server
305-
// instance — avoids paying the ~2s Puppeteer startup cost per composition.
306-
// The browser is created lazily and kept alive until the process exits.
307311
const browser = await getThumbnailBrowser();
308-
if (!browser) return null;
312+
if (!browser) {
313+
console.warn("[Studio] Thumbnail: no browser available — Chrome may not be installed");
314+
return null;
315+
}
309316
let page: import("puppeteer-core").Page | null = null;
310317
try {
311318
page = await browser.newPage();
312319
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
313-
// domcontentloaded instead of networkidle2 — CDN scripts (GSAP, Lottie,
314-
// fonts) never reach "idle" and cause a 15s timeout per thumbnail.
315320
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
316-
// Wait for the runtime to register timelines (up to 5s, non-fatal).
317321
await page
318-
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
319-
timeout: 5000,
320-
})
322+
.waitForFunction(
323+
() => {
324+
const w = window as Window & {
325+
__timelines?: Record<string, unknown>;
326+
};
327+
return !!(w.__timelines && Object.keys(w.__timelines).length > 0);
328+
},
329+
{ timeout: 5000 },
330+
)
321331
.catch(() => {});
322332
await page.evaluate((t: number) => {
323-
const win = window as any;
324-
if (win.__player?.seek) win.__player.seek(t);
325-
else if (win.__timeline?.seek) {
326-
win.__timeline.pause();
327-
win.__timeline.seek(t);
333+
const w = window as Window & {
334+
__player?: { seek?: (time: number) => void };
335+
__timelines?: Record<string, { pause?: (time?: number) => void }>;
336+
gsap?: { ticker?: { tick?: () => void } };
337+
};
338+
if (typeof w.__player?.seek === "function") {
339+
w.__player.seek(t);
340+
} else if (w.__timelines) {
341+
for (const tl of Object.values(w.__timelines)) {
342+
tl?.pause?.(t);
343+
}
344+
w.gsap?.ticker?.tick?.();
328345
}
329346
}, opts.seekTime);
330347
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
331348
await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath);
332-
// Let the seek render settle.
349+
await page.evaluate(() => document.fonts?.ready);
333350
await new Promise((r) => setTimeout(r, 200));
334351
await reapplyStudioManualEditsToThumbnailPage(page);
335352
let clip: ScreenshotClip | undefined;
@@ -349,7 +366,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
349366
},
350367
)) as Buffer;
351368
return screenshot;
352-
} catch {
369+
} catch (err) {
370+
console.warn(
371+
"[Studio] Thumbnail generation failed:",
372+
err instanceof Error ? err.message : err,
373+
);
353374
return null;
354375
} finally {
355376
await page?.close().catch(() => {});

packages/core/src/studio-api/routes/thumbnail.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
9999
selectorIndex,
100100
});
101101
if (!buffer) {
102-
return c.json({ error: "Thumbnail generation returned null" }, 500);
102+
return c.json(
103+
{ error: "Thumbnail generation failed — Chrome browser may not be available" },
104+
500,
105+
);
103106
}
104107
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
105108
writeFileSync(cachePath, buffer);

packages/studio/src/hooks/useFrameCapture.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,29 +31,54 @@ export function useFrameCapture({
3131
async (event: MouseEvent<HTMLAnchorElement>) => {
3232
if (!projectId) return;
3333
event.preventDefault();
34-
const time = usePlayerStore.getState().currentTime;
35-
setCaptureFrameTime(time);
36-
await waitForPendingDomEditSaves();
37-
const href = buildFrameCaptureUrl({
38-
projectId,
39-
compositionPath: activeCompPath,
40-
currentTime: time,
41-
});
42-
const filename = buildFrameCaptureFilename(activeCompPath, time);
4334
try {
44-
const response = await fetch(href, { cache: "no-store" });
45-
if (!response.ok) throw new Error(`Capture failed (${response.status})`);
46-
const blob = await response.blob();
47-
const blobUrl = URL.createObjectURL(blob);
48-
const link = document.createElement("a");
49-
link.href = blobUrl;
50-
link.download = filename;
51-
document.body.appendChild(link);
52-
link.click();
53-
link.remove();
54-
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
35+
const time = usePlayerStore.getState().currentTime;
36+
setCaptureFrameTime(time);
37+
await Promise.race([
38+
waitForPendingDomEditSaves(),
39+
new Promise<void>((_, reject) =>
40+
setTimeout(() => reject(new Error("Save queue timed out")), 5000),
41+
),
42+
]);
43+
const href = buildFrameCaptureUrl({
44+
projectId,
45+
compositionPath: activeCompPath,
46+
currentTime: time,
47+
});
48+
const filename = buildFrameCaptureFilename(activeCompPath, time);
49+
const controller = new AbortController();
50+
const timeout = setTimeout(() => controller.abort(), 30000);
51+
try {
52+
const response = await fetch(href, { cache: "no-store", signal: controller.signal });
53+
clearTimeout(timeout);
54+
if (!response.ok) {
55+
let msg = `Capture failed (${response.status})`;
56+
try {
57+
const json = await response.json();
58+
if (json?.error) msg = json.error;
59+
} catch {
60+
/* non-JSON response — use default message */
61+
}
62+
throw new Error(msg);
63+
}
64+
const blob = await response.blob();
65+
const blobUrl = URL.createObjectURL(blob);
66+
const link = document.createElement("a");
67+
link.href = blobUrl;
68+
link.download = filename;
69+
document.body.appendChild(link);
70+
link.click();
71+
link.remove();
72+
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
73+
} catch (fetchErr) {
74+
clearTimeout(timeout);
75+
if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") {
76+
throw new Error("Capture timed out — the server took too long to respond");
77+
}
78+
throw fetchErr;
79+
}
5580
} catch (err) {
56-
showToast(err instanceof Error ? err.message : "Capture failed");
81+
showToast(err instanceof Error ? err.message : "Capture failed", "error");
5782
}
5883
},
5984
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],

0 commit comments

Comments
 (0)