Skip to content

Commit 47789e1

Browse files
Merge pull request #1061 from heygen-com/fix/snapshot-seek-parity
fix(core,cli): defer __renderReady until root timeline is bound
2 parents b8d521e + 1d09e6f commit 47789e1

5 files changed

Lines changed: 132 additions & 199 deletions

File tree

packages/cli/src/commands/snapshot.ts

Lines changed: 53 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,12 @@ async function captureSnapshots(
9797

9898
const numFrames = opts.frames ?? 5;
9999

100-
// 1. Bundle. `bundleToSingleHtml` now inlines the runtime IIFE by default,
101-
// so the previous post-bundle runtime substitution is no longer needed.
102100
const html = await bundleToSingleHtml(projectDir);
103-
104101
const server = await serveStaticProjectHtml(projectDir, html);
105102

106103
const savedPaths: string[] = [];
107104

108105
try {
109-
// 3. Launch headless Chrome
110106
const browser = await ensureBrowser();
111107
const puppeteer = await import("puppeteer-core");
112108
const chromeBrowser = await puppeteer.default.launch({
@@ -131,60 +127,33 @@ async function captureSnapshots(
131127
timeout: 10000,
132128
});
133129

134-
// Wait for runtime to initialize and sub-compositions to load
130+
// __renderReady is set after the player is constructed AND the root
131+
// timeline is bound — waiting for it guarantees renderSeek will work.
135132
const timeoutMs = opts.timeout ?? 5000;
136-
await page
137-
.waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, {
138-
timeout: timeoutMs,
139-
})
140-
.catch(() => {});
133+
const runtimeReady = await page
134+
.waitForFunction(() => !!(window as any).__renderReady, { timeout: timeoutMs })
135+
.then(() => true)
136+
.catch(() => false);
137+
138+
if (!runtimeReady) {
139+
console.warn(
140+
`\n ${c.warn("⚠")} Runtime did not become render-ready within ${timeoutMs}ms — snapshots may be inaccurate`,
141+
);
142+
}
141143

142-
// Wait for ALL sub-compositions to be mounted by the runtime.
143-
// The old check resolved when the first sub-timeline registered, causing
144-
// "last beat black" bugs: beat-5's sub-comp hadn't loaded yet when the
145-
// snapshot seeked into its time range. Now we count data-composition-src
146-
// host elements and wait until we have a matching number of sub-timelines.
147-
await page
148-
.waitForFunction(
149-
() => {
150-
const tls = (window as any).__timelines;
151-
if (!tls) return false;
152-
const hosts = document.querySelectorAll("[data-composition-src]").length;
153-
if (hosts === 0) return Object.keys(tls).length >= 1;
154-
const subKeys = Object.keys(tls).filter((k) => k !== "main");
155-
return subKeys.length >= hosts;
156-
},
157-
{ timeout: timeoutMs },
158-
)
159-
.catch(() => {});
160-
161-
// Wait for shader transition pre-rendering to complete (if active).
162-
//
163-
// Two failure modes existed with the previous overlay-only check:
164-
// 1. Cold cache: HyperShader creates [data-hyper-shader-loading] but never
165-
// removes it from the DOM — it only sets display:none. Checking for
166-
// element *absence* never resolved, so the wait always timed out at 60s.
167-
// 2. Warm cache: HyperShader loads frames from IndexedDB without showing
168-
// the overlay at all. Checking for element absence resolved instantly
169-
// (no element) while hydration was still running in the background.
170-
//
171-
// Fix: use window.__hf.shaderTransitions[].ready as the primary signal
172-
// (set after both warm and cold cache paths complete), with the overlay
173-
// display:none as a fallback for older builds that lack the ready state.
144+
// Wait for shader transition pre-rendering (HyperShader IndexedDB hydration).
145+
// Uses the ready state flag as primary signal, with the loading overlay
146+
// display:none as a fallback for older builds.
174147
await page
175148
.waitForFunction(
176149
() => {
177150
const win = window as unknown as {
178151
__hf?: { shaderTransitions?: Record<string, { ready?: boolean }> };
179152
};
180-
// Primary: HyperShader ready state — authoritative for both cache paths
181153
const shaderTransitions = win.__hf?.shaderTransitions;
182154
if (shaderTransitions !== undefined) {
183155
return Object.values(shaderTransitions).every((s) => s.ready === true);
184156
}
185-
// Fallback: overlay visibility (older builds without ready state).
186-
// Check display:none rather than element absence — element stays in
187-
// the DOM when hidden.
188157
const overlay = document.querySelector(
189158
"[data-hyper-shader-loading]",
190159
) as HTMLElement | null;
@@ -193,9 +162,14 @@ async function captureSnapshots(
193162
},
194163
{ timeout: 90_000 },
195164
)
196-
.catch(() => {});
165+
.catch(() => {
166+
console.warn(` ${c.warn("⚠")} Shader transitions did not finish pre-rendering`);
167+
});
197168

198-
// Extra settle time for media, fonts, and animations to initialize
169+
// Wait for fonts to finish loading before capturing
170+
await page.evaluate(() => document.fonts.ready).catch(() => {});
171+
172+
// Extra settle time for media and animations to initialize
199173
await new Promise((r) => setTimeout(r, 1500));
200174

201175
// Font verification — report which fonts loaded vs fell back
@@ -221,20 +195,14 @@ async function captureSnapshots(
221195
}
222196
}
223197

224-
// Get composition duration
225198
const duration = await page.evaluate(() => {
226199
const win = window as any;
227-
const pd = win.__player?.duration;
228-
if (pd != null) return typeof pd === "function" ? pd() : pd;
200+
if (typeof win.__player?.getDuration === "function") {
201+
const d = win.__player.getDuration();
202+
if (Number.isFinite(d) && d > 0) return d;
203+
}
229204
const root = document.querySelector("[data-composition-id][data-duration]");
230205
if (root) return parseFloat(root.getAttribute("data-duration") ?? "0");
231-
const tls = win.__timelines;
232-
if (tls) {
233-
for (const key in tls) {
234-
const d = tls[key]?.duration;
235-
if (d != null) return typeof d === "function" ? d() : d;
236-
}
237-
}
238206
return 0;
239207
});
240208

@@ -249,27 +217,21 @@ async function captureSnapshots(
249217
? [duration / 2]
250218
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
251219

252-
// Create output directory and clear previous frames so old captures
253-
// don't mix with the current run in contact sheets.
254220
const snapshotDir = join(projectDir, "snapshots");
255221
mkdirSync(snapshotDir, { recursive: true });
256222
try {
257-
const { readdirSync, rmSync } = await import("node:fs");
223+
const { readdirSync } = await import("node:fs");
258224
for (const file of readdirSync(snapshotDir)) {
259225
if (/\.(png|jpg|jpeg)$/i.test(file)) {
260226
rmSync(join(snapshotDir, file), { force: true });
261227
}
262228
}
263229
} catch {
264-
/* best-effort clear — proceed even if cleanup fails */
230+
/* best-effort — proceed even if cleanup fails */
265231
}
266232

267-
// Lazily load the engine's <img>-overlay injector. Chrome-headless cannot
268-
// reliably advance <video>.currentTime mid-seek (the setter is accepted but
269-
// the decoder ignores it without user activation), so the render pipeline
270-
// already extracts each frame via FFmpeg and injects it as an <img> sibling
271-
// over the <video>. We reuse that same primitive here so `snapshot` and
272-
// `render` behave identically for timed <video data-start> elements.
233+
// Chrome-headless ignores programmatic <video>.currentTime writes, so
234+
// we extract frames via FFmpeg and overlay them as <img> elements.
273235
type InjectFn = (
274236
page: unknown,
275237
updates: Array<{ videoId: string; dataUri: string }>,
@@ -306,57 +268,36 @@ async function captureSnapshots(
306268
return pending;
307269
};
308270

309-
// Seek and capture each frame
271+
const hasPlayer = await page.evaluate(() => !!(window as any).__player);
272+
if (!hasPlayer) {
273+
console.warn(` ${c.warn("⚠")} No player API — seeks will be skipped`);
274+
}
275+
310276
for (let i = 0; i < positions.length; i++) {
311277
const time = positions[i]!;
312278

313279
await page.evaluate((t: number) => {
314-
const win = window as any;
315-
if (win.__player?.seek) {
316-
win.__player.seek(t);
317-
} else {
318-
const tls = win.__timelines;
319-
if (tls) {
320-
for (const key in tls) {
321-
if (tls[key]?.seek) {
322-
// Sub-composition timelines run in local time relative to
323-
// their data-start. Seeking them to global time causes beats
324-
// with exit animations to appear black (global t clamps past
325-
// the exit). Compute local time: global_t - data_start.
326-
const host = document.querySelector<HTMLElement>(
327-
`[data-composition-id="${key}"]`,
328-
);
329-
const dataStart = host
330-
? parseFloat(host.getAttribute("data-start") ?? "0") || 0
331-
: 0;
332-
const localTime = Math.max(0, t - dataStart);
333-
tls[key].pause();
334-
tls[key].seek(localTime);
335-
}
336-
}
337-
}
280+
const player = (window as any).__player;
281+
if (!player) return;
282+
const safe = Math.max(0, Number(t) || 0);
283+
if (typeof player.renderSeek === "function") {
284+
player.renderSeek(safe);
285+
} else if (typeof player.seek === "function") {
286+
player.seek(safe);
287+
}
288+
if ((window as any).gsap?.ticker?.tick) {
289+
(window as any).gsap.ticker.tick();
338290
}
339291
}, time);
340292

341-
// Wait for rendering to settle after seek
342-
await page.evaluate(
343-
() =>
344-
new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))),
345-
);
346-
await new Promise((r) => setTimeout(r, 200));
293+
await page.evaluate(`new Promise(function(r) {
294+
var settled = false;
295+
function finish() { if (settled) return; settled = true; r(); }
296+
window.setTimeout(finish, 100);
297+
requestAnimationFrame(function() { requestAnimationFrame(finish); });
298+
})`);
347299

348-
// ─── Inject real video frames over any active <video data-start> ───
349-
// Without this, Chrome-headless renders them blank/first-frame because
350-
// it silently drops programmatic `currentTime` writes during capture.
351-
// No-op when the composition has no timed videos (basecamp, linear, etc.)
352300
if (injectVideoFramesBatch && syncVideoFrameVisibility) {
353-
// Mirror the runtime's media math in packages/core/src/runtime/media.ts
354-
// so clips with non-1 `defaultPlaybackRate` get the right active
355-
// window and the right `relTime`:
356-
// playbackRate = clamp(defaultPlaybackRate, 0.1, 5) — default 1
357-
// duration fallback = (sourceDuration - mediaStart) / playbackRate
358-
// relTime = (t - start) * playbackRate + mediaStart
359-
// active = t >= start && t < start+duration && relTime >= 0
360301
const active = await page.evaluate((t: number) => {
361302
return Array.from(document.querySelectorAll("video[data-start]"))
362303
.map((el) => {
@@ -392,11 +333,6 @@ async function captureSnapshots(
392333

393334
const updates: Array<{ videoId: string; dataUri: string }> = [];
394335
for (const v of active) {
395-
// The page-served URL (http://127.0.0.1:PORT/relative/path.mp4)
396-
// maps 1:1 to <projectDir>/relative/path.mp4. decodeURIComponent
397-
// the pathname — the file server decodes inbound requests, so a
398-
// file with spaces in its path lives at the decoded name on disk
399-
// while `new URL().pathname` preserves the %-encoding.
400336
let filePath: string | null = null;
401337
try {
402338
const url = new URL(v.src);
@@ -422,12 +358,7 @@ async function captureSnapshots(
422358
});
423359
}
424360

425-
// Always run the visibility sync — even when `active` is empty and
426-
// no new updates were injected. Without this, stale __render_frame__
427-
// <img> overlays left by a previous seek (where different clips were
428-
// active) remain visible in later snapshots, because the runtime's
429-
// visibility toggles act on the <video> element but not its injected
430-
// <img> sibling.
361+
// Sync visibility even when empty — clears stale overlays from prior seeks
431362
try {
432363
if (updates.length > 0) {
433364
await injectVideoFramesBatch(page, updates);
@@ -437,8 +368,7 @@ async function captureSnapshots(
437368
active.map((a) => a.id),
438369
);
439370
} catch {
440-
// If either step fails, fall through to the plain screenshot —
441-
// no worse than the pre-fix behaviour.
371+
/* fall through to plain screenshot */
442372
}
443373
}
444374

0 commit comments

Comments
 (0)