Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
59 changes: 40 additions & 19 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {});
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/studio-api/routes/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
67 changes: 46 additions & 21 deletions packages/studio/src/hooks/useFrameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,29 +31,54 @@ export function useFrameCapture({
async (event: MouseEvent<HTMLAnchorElement>) => {
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<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", 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],
Expand Down
Loading