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 6d115df4ba..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; @@ -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 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],