From 4e0034f072aacd7a2fd616cb35a33418b05c69dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 22:00:08 +0200 Subject: [PATCH] fix(studio): fix capture button silent failures and broken CLI seek (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- packages/cli/src/commands/snapshot.ts | 23 +++---- packages/cli/src/server/studioServer.ts | 59 ++++++++++------ .../core/src/studio-api/routes/thumbnail.ts | 5 +- packages/studio/src/hooks/useFrameCapture.ts | 67 +++++++++++++------ 4 files changed, 101 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 0fd2a81da..9a9f18bb4 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -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 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); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 6d115df4b..7a491a915 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -138,7 +138,10 @@ async function getThumbnailBrowser(): Promise { _thumbnailBrowser = null; @@ -155,7 +158,11 @@ async function getThumbnailBrowser(): Promise 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 { - // 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; + }; + 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 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(() => {}); diff --git a/packages/core/src/studio-api/routes/thumbnail.ts b/packages/core/src/studio-api/routes/thumbnail.ts index 87290fb95..1b3316e47 100644 --- a/packages/core/src/studio-api/routes/thumbnail.ts +++ b/packages/core/src/studio-api/routes/thumbnail.ts @@ -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); diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts index e777206bb..3cba1d408 100644 --- a/packages/studio/src/hooks/useFrameCapture.ts +++ b/packages/studio/src/hooks/useFrameCapture.ts @@ -31,29 +31,54 @@ export function useFrameCapture({ async (event: MouseEvent) => { if (!projectId) return; event.preventDefault(); - const time = usePlayerStore.getState().currentTime; - setCaptureFrameTime(time); - await waitForPendingDomEditSaves(); - const href = buildFrameCaptureUrl({ - projectId, - compositionPath: activeCompPath, - currentTime: time, - }); - const filename = buildFrameCaptureFilename(activeCompPath, time); try { - const response = await fetch(href, { cache: "no-store" }); - if (!response.ok) throw new Error(`Capture failed (${response.status})`); - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = blobUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 0); + const time = usePlayerStore.getState().currentTime; + setCaptureFrameTime(time); + await Promise.race([ + waitForPendingDomEditSaves(), + new Promise((_, 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", 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"); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + 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],