mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user