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.
This commit is contained in:
Miguel Ángel
2026-05-16 22:00:08 +02:00
committed by GitHub
parent 3569c4830c
commit 4e0034f072
4 changed files with 101 additions and 53 deletions
+11 -12
View File
@@ -237,19 +237,18 @@ async function captureSnapshots(
const time = positions[i]!;
await page.evaluate((t: number) => {
const win = window as any;
if (win.__player?.seek) {
win.__player.seek(t);
} else {
const tls = win.__timelines;
if (tls) {
for (const key in tls) {
if (tls[key]?.seek) {
tls[key].pause();
tls[key].seek(t);
}
}
const w = window as Window & {
__player?: { seek?: (time: number) => void };
__timelines?: Record<string, { pause?: (time?: number) => void }>;
gsap?: { ticker?: { tick?: () => void } };
};
if (typeof w.__player?.seek === "function") {
w.__player.seek(t);
} else if (w.__timelines) {
for (const tl of Object.values(w.__timelines)) {
tl?.pause?.(t);
}
w.gsap?.ticker?.tick?.();
}
}, time);
+40 -19
View File
@@ -138,7 +138,10 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
/* continue — acquireBrowser will try its own resolution */
}
const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }));
const acquired = await acquireBrowser(
buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
{ forceScreenshot: true },
);
_thumbnailBrowser = acquired.browser;
_thumbnailBrowser.on("disconnected", () => {
_thumbnailBrowser = null;
@@ -155,7 +158,11 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
process.once("SIGTERM", () => void onExit());
process.once("SIGINT", () => void onExit());
return _thumbnailBrowser;
} catch {
} catch (err) {
console.warn(
"[Studio] Failed to launch thumbnail browser:",
err instanceof Error ? err.message : err,
);
_thumbnailBrowserInitializing = null;
return null;
}
@@ -301,35 +308,45 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
},
async generateThumbnail(opts): Promise<Buffer | null> {
// Reuse a single browser across all thumbnail requests for this server
// instance — avoids paying the ~2s Puppeteer startup cost per composition.
// The browser is created lazily and kept alive until the process exits.
const browser = await getThumbnailBrowser();
if (!browser) return null;
if (!browser) {
console.warn("[Studio] Thumbnail: no browser available — Chrome may not be installed");
return null;
}
let page: import("puppeteer-core").Page | null = null;
try {
page = await browser.newPage();
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
// domcontentloaded instead of networkidle2 — CDN scripts (GSAP, Lottie,
// fonts) never reach "idle" and cause a 15s timeout per thumbnail.
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
// Wait for the runtime to register timelines (up to 5s, non-fatal).
await page
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
timeout: 5000,
})
.waitForFunction(
() => {
const w = window as Window & {
__timelines?: Record<string, unknown>;
};
return !!(w.__timelines && Object.keys(w.__timelines).length > 0);
},
{ timeout: 5000 },
)
.catch(() => {});
await page.evaluate((t: number) => {
const win = window as any;
if (win.__player?.seek) win.__player.seek(t);
else if (win.__timeline?.seek) {
win.__timeline.pause();
win.__timeline.seek(t);
const w = window as Window & {
__player?: { seek?: (time: number) => void };
__timelines?: Record<string, { pause?: (time?: number) => void }>;
gsap?: { ticker?: { tick?: () => void } };
};
if (typeof w.__player?.seek === "function") {
w.__player.seek(t);
} else if (w.__timelines) {
for (const tl of Object.values(w.__timelines)) {
tl?.pause?.(t);
}
w.gsap?.ticker?.tick?.();
}
}, opts.seekTime);
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath);
// Let the seek render settle.
await page.evaluate(() => document.fonts?.ready);
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioManualEditsToThumbnailPage(page);
let clip: ScreenshotClip | undefined;
@@ -349,7 +366,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
},
)) as Buffer;
return screenshot;
} catch {
} catch (err) {
console.warn(
"[Studio] Thumbnail generation failed:",
err instanceof Error ? err.message : err,
);
return null;
} finally {
await page?.close().catch(() => {});
@@ -99,7 +99,10 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
selectorIndex,
});
if (!buffer) {
return c.json({ error: "Thumbnail generation returned null" }, 500);
return c.json(
{ error: "Thumbnail generation failed — Chrome browser may not be available" },
500,
);
}
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, buffer);
+29 -4
View File
@@ -31,18 +31,36 @@ export function useFrameCapture({
async (event: MouseEvent<HTMLAnchorElement>) => {
if (!projectId) return;
event.preventDefault();
try {
const time = usePlayerStore.getState().currentTime;
setCaptureFrameTime(time);
await waitForPendingDomEditSaves();
await Promise.race([
waitForPendingDomEditSaves(),
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error("Save queue timed out")), 5000),
),
]);
const href = buildFrameCaptureUrl({
projectId,
compositionPath: activeCompPath,
currentTime: time,
});
const filename = buildFrameCaptureFilename(activeCompPath, time);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(href, { cache: "no-store" });
if (!response.ok) throw new Error(`Capture failed (${response.status})`);
const response = await fetch(href, { cache: "no-store", signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
let msg = `Capture failed (${response.status})`;
try {
const json = await response.json();
if (json?.error) msg = json.error;
} catch {
/* non-JSON response — use default message */
}
throw new Error(msg);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -52,8 +70,15 @@ export function useFrameCapture({
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
} catch (fetchErr) {
clearTimeout(timeout);
if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") {
throw new Error("Capture timed out — the server took too long to respond");
}
throw fetchErr;
}
} catch (err) {
showToast(err instanceof Error ? err.message : "Capture failed");
showToast(err instanceof Error ? err.message : "Capture failed", "error");
}
},
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],