From d422daceba97bf2d5007256c2bd9a823f3288c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 12:36:27 -0700 Subject: [PATCH 1/3] fix(studio): fix capture button silent failures and broken CLI seek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/cli/src/server/studioServer.ts | 52 +++++++++----- .../core/src/studio-api/routes/thumbnail.ts | 5 +- packages/studio/src/hooks/useFrameCapture.ts | 67 +++++++++++++------ 3 files changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 6d115df4ba..81ca557148 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -155,7 +155,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 +305,43 @@ 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( + () => + !!( + (window as any).__timelines && Object.keys((window as any).__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 +361,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 87290fb958..1b3316e471 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 e777206bb0..3cba1d4081 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], From 74260d7fca51234384447cd9a3df4bda8ece1f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 12:53:21 -0700 Subject: [PATCH 2/3] fix(cli): apply same seek fix to snapshot command, address review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/cli/src/commands/snapshot.ts | 23 +++++++++++------------ packages/cli/src/server/studioServer.ts | 12 +++++++----- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 0fd2a81dab..9a9f18bb42 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 81ca557148..b356fb7755 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -317,10 +317,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); await page .waitForFunction( - () => - !!( - (window as any).__timelines && Object.keys((window as any).__timelines).length > 0 - ), + () => { + const w = window as Window & { + __timelines?: Record; + }; + return !!(w.__timelines && Object.keys(w.__timelines).length > 0); + }, { timeout: 5000 }, ) .catch(() => {}); @@ -341,7 +343,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, opts.seekTime); const manifestContent = readStudioManualEditManifestContent(opts.project.dir); await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath); - await page.evaluate("document.fonts?.ready"); + await page.evaluate(() => document.fonts?.ready); await new Promise((r) => setTimeout(r, 200)); await reapplyStudioManualEditsToThumbnailPage(page); let clip: ScreenshotClip | undefined; From 3fa8a383969cb7efad67850f16225ec261f74d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 16 May 2026 12:58:38 -0700 Subject: [PATCH 3/3] fix(cli): force screenshot mode for thumbnail browser on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/server/studioServer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index b356fb7755..7a491a9151 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;