Skip to content

fix(producer): page timers during a render run on film time - #7362

Merged
miguel-heygen merged 8 commits into
mainfrom
fix/render-timers-on-film-time
Nov 7, 2026
Merged

miguel-heygen merged 8 commits into
mainfrom
fix/render-timers-on-film-time

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

Page timers set during a render now run on film time. setTimeout and setInterval callbacks set once rendering has started fire at the seek that reaches their due time, in due order. Inside the callback, Date.now() and performance.now() read the timer's due time. Until the renderer seeks a page that reports ready (window.__renderReady), timers still run on the wall clock, so page setup behaves as before. The renderer's own waits and deadlines, in the runtime and in the engine's page-side code, stay on the wall clock throughout.

Why

The render's virtual-time shim already freezes Date, performance.now and requestAnimationFrame to film time, but it left timers on the wall clock. Motion driven by a timer therefore advanced at whatever speed frames happened to be captured, not by frame number. A 100 ms setTimeout chain fired every 2 frames at 30 fps instead of every 3, about 1.5x film speed, on every version tested. Because capture speed changes between releases and machines, the same film could render differently from one version to the next.

Related work

#6599 also edits buildVirtualTimeShim, to drain pending animation frames before a PNG-sequence capture. The two changes touch different parts of the shim.

How

  • buildVirtualTimeShim in packages/producer/src/services/fileServer.ts replaces setTimeout, setInterval, clearTimeout and clearInterval.
    • Until a seekToTime arrives while window.__renderReady is true, they pass straight to the browser's timers. That keeps every timer set during page setup on the wall clock, even if a seek arrives before setup ends.
    • After that, a timer goes into a queue keyed by its due time on film time. seekToTime(t) fires every queued timer due by t, earliest first, before the frame's animation callbacks run.
  • The runtime's and the engine's own timers keep the wall clock. Many of them are deadlines on a wait that the engine awaits between two seeks: the canvas-paint cap, waitForSeekCompletion's timeout, the CSS-background decode race, the drawElement paint fallback. On film time, such a deadline would fire only at the next seek, which waits for it, so a canvas that never paints would hang the frame until the protocol timeout.
    • packages/core/src/runtime/wallClock.ts exports wallSetTimeout, wallClearTimeout, wallSetInterval and wallClearInterval. They call the shim's originalSetTimeout and the other originals when window.__HF_VIRTUAL_TIME__ is present, the convention hf-early-stub.ts already follows, and the plain timers otherwise.
    • Every timer call in packages/core/src/runtime goes through them: 51 call sites in 15 files. A test fails if a runtime source file calls setTimeout, clearTimeout, setInterval or clearInterval directly.
    • The engine's page-side code can't import the helper, because page.evaluate serializes each function. Its seven page-side timers call the shim's originalSetTimeout inline, falling back to setTimeout. They are the CSS-background decode race and the registry-settle macrotask in frameCapture.ts, the encode hand-off and the paint fallbacks of the single, pipelined and batch captures in drawElementService.ts, and the WebGPU device probe's deadline in browserManager.ts.
    • pageTimers.test.ts parses every engine source and fails on a bare setTimeout or setInterval call in code the engine runs in the page. That covers every function passed to evaluate, evaluateOnNewDocument, evaluateHandle, $eval, $$eval or waitForFunction (inline or by name), and every page-script string.
  • A timer set from inside a firing timer waits at least 1 ms. A zero-delay chain therefore advances film time and cannot spin a seek forever.
  • A timer due exactly on a frame fires on that frame, even though frame times like 1000/30 ms are fractional and the sum lands a rounding error past the frame.
  • An exception thrown by a timer is rethrown asynchronously, as the browser reports it, without stopping the other due timers.

Test plan

  • Unit tests added in fileServer-virtualTimers.test.ts. They run the shim in a VM and seek it frame by frame at 30 fps:
    • setup timers stay on the wall clock, including while seeks come before the page reports ready;
    • a 100 ms chain fires every 100 ms of film time and reads its due time;
    • a chain started on a fractional frame time fires on frames 4, 7, 10 and so on;
    • an interval fires until it is cleared, and a cleared timeout never fires;
    • a zero-delay chain gets 1 ms per step, so a seek ends;
    • due timers run before the frame's animation callbacks.
  • seek-dispatch.test.ts: with page timers held for the next seek, as after a ready seek, waitForSeekCompletion({ timeoutMs: 50 }) on a hold that never settles throws seek did not settle within 50 ms. Fails without the fix: at 331ce783 it is still pending after 1 s.
  • wallClock.test.ts: the helper uses the shim's original timers while page timers are held, and no runtime source calls a timer directly. Fails without the fix: routing settledBy back to setTimeout fails the deadline test and the source scan; dropping the shim lookup from the helper fails the helper test and the deadline test.
  • pageTimers.test.ts: on this head it finds no bare page-side timer, and its own case finds the three shapes (inline function, function passed by name, script string). Fails without the fix: with 331ce783's page code it lists all seven sites. The WebGPU probe was one my hand grep had filed as Node-side.
  • Fails without the fix: each guard removed in turn fails one of these tests. That covers the setup guard, the ready gate, starting on a ready seek, interval repeat, the 1 ms floor, clearing, timers before animation frames, and the fractional-frame tolerance.
  • fileServer-virtualTimers.test.ts (8 tests) passes 3 runs in a row; fileServer.test.ts and fileServer-seededRandom.test.ts pass (68 tests); producer tsc --noEmit, oxlint, oxfmt and test classification are clean.
  • A counter film rendered through the producer server: the 100 ms timer bar now steps every 3 frames exactly with 1 worker and with automatic workers. Before, it stepped every 2 frames. requestAnimationFrame, element.animate, CSS transitions and GSAP bars are unchanged.
  • New regression fixture timers-on-film-time: a 100 ms setTimeout chain and a 250 ms setInterval started from the first animation frame, against a performance.now reference bar, all at 200 px per second of film time. Its baseline was generated in Docker from this branch; all three bars match their film-time widths on all 90 frames, and the harness passes it against that baseline.
  • The same film rendered by main's producer: the timeout bar is wrong on 87 of 90 frames and the interval bar on 83, while the reference bar matches.
  • Producer regression baselines before and after. No existing baseline changes; the only new output is the timers-on-film-time fixture above.
    • CI's regression job passes on all 10 shards.
    • Locally in Docker, the branch image passes 73 of 79 fixtures. The other 6 fail the same way on main's image: fast-capture-gsap, heygen-promo-preview-assets, page-side-shader-compositor-render-compat and webgl-video-texture-render-compat fail the same number of frames on both images (100, 18, 41 and 99). d3-adapter and maplibre-adapter load their libraries from public CDNs and time out in setup on both images on that machine; CI renders them.

Timers checked

Every timer call in the runtime and the engine, found with:

grep -rnE "\bset(Timeout|Interval)\(" packages/core/src/runtime packages/engine/src --include=*.ts | grep -vE "\.test\.ts|__fixtures__"
  • packages/core/src/runtime: all 26 timer starts and their clears now go through wallClock.ts. They are afterFonts.ts (font wait), webAudioTransport.ts (2), mediaProxy.ts (shownScale wait, hold cap, copies-ahead check), proxyWait.ts, colorGrading.ts (context-restore grace), bridge.ts (flash), media.ts (seek-barrier cap), init.ts (8: debounce, task yields, caps, transport timers), captureMotionBlur/index.ts, vfx.ts (awaitCanvasPaint), previewCopies.ts, filmBridge.ts (2), adapters/seek-dispatch.ts (settledBy), adapters/typegpu.ts (present heartbeat) and compositionLoader.ts (external script timeout). positionEdits.ts calls target.setInterval on a Studio editing window, not in a render, and is left as is.
  • packages/engine/src: seven calls run in the page and now use the shim's original: frameCapture.ts 2, drawElementService.ts 4 and browserManager.ts 1 (the WebGPU probe). pageTimers.test.ts keeps it that way. The other 29 run in Node, where the shim does not reach: the rest of browserManager.ts, parallelCoordinator.ts, chromeStartup.ts, browserLeasePool.ts, screenshotService.ts, pageMotionBlur.ts, riskFrames.ts, urlDownloader.ts, managedChildProcess.ts, processTracker.ts, the Node-side polls and races in frameCapture.ts and the encode watchdogs in drawElementService.ts.
  • packages/producer/src: the page scripts already use the shim's originals (hf-early-stub.ts and the shim itself); every other timer runs in Node.

Size

One shim function and its tests, plus a four-function timer helper that the runtime and the engine's page code route through.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 117 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 7, 2026
…, heygen-com#7360, heygen-com#7347); review to PR heygen-com#7362

Adopt heygen-com#7357 and heygen-com#7361 (upload completion waits and retries), heygen-com#7360 (Chrome
start failure names its cause in check) and heygen-com#7347 (Safari perf tooling);
release v0.9.20 not applicable. Pin open heygen-com#7362 (needs its golden LFS pointer
when adopted); record issue heygen-com#7363. Monitored 126 -> 127.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at 331ce783. CI is green, and the "fails without the fix" line is acceptable: it names fileServer-virtualTimers.test.ts and the timers-on-film-time fixture. The blocker is a side effect on the renderer's own safety timeouts.

Blocker: page-side timeouts the engine waits on can no longer fire

After the first ready seek, every setTimeout on the page goes to the film-time queue. That includes the runtime's and the engine's own timeouts. Several of these are deadlines on waits that the engine awaits after seeking frame N and before seeking frame N+1. Under this shim, a queued timer fires only on a later seekToTime. If the awaited thing never settles, the deadline never arrives, and the frame stalls until Puppeteer's CDP protocolTimeout (~180 s). Today it fails or falls back after a short, bounded wait.

  • core/src/runtime/vfx.ts, awaitCanvasPaint: the 2 s CAPTURE_PAINT_TIMEOUT_MS. Its own doc says this deadline is the only way out on a BeginFrame host. The engine drains this barrier in prepareFrameForCapture (waitForPendingSeekCompletion in frameCapture.ts) before the per-frame beginFrame. The rAF fallback is already on film time under the shim, so after this PR nothing can settle the barrier before the next seek, and the next seek never comes.
  • core/src/runtime/adapters/seek-dispatch.ts, settledBy: the waitForSeekCompletion({ timeoutMs }) deadline is a setTimeout. Date.now() is already frozen, so this timer was the only thing enforcing the timeout.
  • engine/src/services/frameCapture.ts, decodeDynamicCssBackgroundImages: the in-page setTimeout(() => resolve("late"), limitMs) race against image.decode().
  • engine/src/services/drawElementService.ts, waitPaint: the setTimeout(settle, 250) paint fallback. The vfx docs describe it as firing every frame under BeginFrame control.
  • Also affected: mediaProxy.ts shownScale (DISPLAY_SCALE_WAIT_MS) and any other Promise.race([..., setTimeout]) shape that runs after the ready seek.

I reproduced the mechanism with the shim from this branch and from main in a VM. Both got real timers, __renderReady = true and one seekToTime(0), then ran Promise.race([never, new Promise(r => setTimeout(() => r("late"), 50))]):

  • This branch: still pending after 1000 ms of real time.
  • main: late.

The regression fixtures pass because in them every hold settles through its normal path. The timeouts exist for the cases where that doesn't happen: a canvas that never paints, an image whose decode hangs, BeginFrame drawelement capture.

Suggested direction

  • Keep engine and runtime deadlines on the wall clock. The codebase already has a convention for this: hf-early-stub.ts uses window.__HF_VIRTUAL_TIME__.originalSetTimeout. Route at least settledBy, awaitCanvasPaint, waitPaint, the CSS-background decode race and shownScale through it. The cleanest way is a small helper in core that prefers __HF_VIRTUAL_TIME__?.originalSetTimeout/originalClearTimeout when present.
  • The other option is to virtualize only composition timers, but the shim can't tell callers apart. Going through the original timers is the practical fix.
  • Add a test that fails on this head: after a ready seek, a wait raced against a setTimeout deadline still settles within real time. For example, waitForSeekCompletion({ timeoutMs: 50 }) with a never-settling hold throws SeekNotSettledError, or awaitCanvasPaint on a canvas that never paints returns "timeout".

Non-blocking

  • clearTimeout on a virtual id that already fired falls through to the browser's clearTimeout(1e9+n). That's harmless.
  • fireDueTimers does a linear scan per fire, which is fine at page scale.
  • The film-time semantics in the description are right for composition code, and the new fixture is a good regression guard.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks, this is right. A deadline the engine awaits between two seeks could only fire at the next seek, so a hold that never settled waited until the protocol timeout. Fixed; the branch head is now b6c2bfad:

  • Red test first: seek-dispatch.test.ts holds page timers for the next seek, as the shim does after a ready seek, and expects waitForSeekCompletion({ timeoutMs: 50 }) on a never-settling hold to throw seek did not settle within 50 ms. At 331ce783 it was still pending after 1 s; it now throws.
  • One owner: core/src/runtime/wallClock.ts (wallSetTimeout, wallClearTimeout, wallSetInterval, wallClearInterval). It prefers window.__HF_VIRTUAL_TIME__'s original timers, the hf-early-stub.ts convention, and falls back to the plain timers when there is no shim.
  • Every timer call in core/src/runtime routes through it, not only the deadlines you named. That keeps the runtime's own timers exactly as on main. wallClock.test.ts fails if a runtime source calls a timer directly.
  • The engine's seven page-side timers can't import a helper, since page.evaluate serializes each function, so they call the shim's original inline. These are the CSS-background decode race and the registry-settle macrotask in frameCapture.ts, the encode hand-off and three paint fallbacks in drawElementService.ts, and the WebGPU probe deadline in browserManager.ts. The other engine timers run in Node.
  • pageTimers.test.ts parses the engine sources and fails on a bare timer in any function or script string the engine runs in the page. On 331ce783's page code it lists all seven sites. One of them, the WebGPU probe, I had first misfiled as Node-side; the scan caught it.
  • The full grep and the per-file list are in the description under "Timers checked".
  • Full producer regression run in Docker, on the image from f3397bdf: 73 of 79 pass. The other 6 fail the same way on main's image. Four of them fail the same number of frames there (100, 18, 41, 99), and the d3 and MapLibre fixtures time out on that machine's CDN. The two later commits only change types and remove a comment. CI's regression shards cover the head.

On the non-blocking points: agreed. The fall-through clearTimeout stays, because it is what lets a wall-clock id be cleared through the shim's clearTimeout.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at b6c2bfad. This head resolves my earlier blocker.

  • The renderer's waits are back on the wall clock. wallClock.ts resolves __HF_VIRTUAL_TIME__.original* at call time and falls back to the plain timers, the same convention hf-early-stub.ts follows.
    • Covered in the runtime: settledBy, awaitCanvasPaint, shownScale, the media seek-barrier cap and the other runtime timers.
    • Covered in the engine's page-side code: the CSS-background decode race, the drawElement paint and encode fallbacks, the registry-settle macrotask and the WebGPU probe. These use the shim's original timers inline.
  • The fails-without-the-fix claim holds. I put settledBy back on bare setTimeout. Two tests then fail: a seek's deadline still fires while the render shim holds page timers for the next seek (pending after about 1 s) and are the only timers the runtime calls (17 tests, 2 failed). With the fix restored, wallClock.test.ts and seek-dispatch.test.ts pass 17 of 17, and pageTimers.test.ts passes 2 of 2.
  • The guards are well shaped. The runtime source scan plus the engine AST scan, which covers page-call arguments, functions passed by name and page-script strings, should stop this class of bug from coming back.
  • CI on this head: all green, including regression, preview-regression, Windows render verification, and Player Safari perf and audio.

Non-blocking, for a follow-up if you want:

  1. Neither scan covers page code outside core/src/runtime and engine/src. What I checked is fine today:
    • core/src/compositionReadiness.ts runs before __renderReady, so its timers stay native.
    • shader-transitions/engineModePageComposite.ts's bridge poll is created before the ready seek.
    • hyper-shader.ts's 240 ms overlay hide would now run on film time if hide() were called mid-render. That's arguably the right behavior.
      Extending the runtime scan to those packages would make the guard complete.
  2. In settledBy, deadline - Date.now() still reads the frozen virtual Date, so the deadline works only because the virtual clock doesn't move inside a wait. A performance.timeOrigin-free wall reading would be sturdier. This was already true before this PR.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit 59c2065 Nov 7, 2026
98 checks passed
@miguel-heygen
miguel-heygen deleted the fix/render-timers-on-film-time branch November 7, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant