fix(core): make three adapter seek robust against late listeners#598
fix(core): make three adapter seek robust against late listeners#598voidborne-d wants to merge 1 commit intoheygen-com:mainfrom
Conversation
The three adapter dispatched seeks via a CustomEvent on window. When a composition's <script type="module"> imports finished after the runtime had already started seeking, the user's hf-seek listener missed every event and the Three.js scene froze on its initial frame (heygen-com#584). Add a callback registry (window.__hfThreeRender) that mirrors the existing __hfAnime / __hfLottie pattern. The adapter now invokes every registered callback synchronously on each seek before dispatching the legacy event. Compositions push their renderAt fn and receive every subsequent seek, regardless of registration timing. The hf-seek event is preserved so existing compositions keep working.
miguel-heygen
left a comment
There was a problem hiding this comment.
Requesting changes because this does not yet prove or fully solve the root issue claimed in #584.
I tested current head 70352d12 against base 4750a981 through the real producer render path after rebuilding the core runtime artifact. The new __hfThreeRender registry itself works: on a WebGL preserveDrawingBuffer: true fixture, the registry path is static on base (first=0f00e6, last=0f00e6) and animates on this PR (first=1800df, last=c40035). The existing immediate hf-seek path also animates on both base and PR (first=1800df, last=c40035), so I could not reproduce the issue description's claim that an already-registered listener never fires.
The remaining blocker is the stated root-cause claim. A deliberately delayed registration still renders static on this PR (first=0f00e6, last=0f00e6) because render capture does not wait for the Three scene/callback to register. So the PR is a useful adapter/API improvement, but it does not support the current claim that seeks reach Three scenes "regardless of when the composition's render code is registered" or that it eliminates the registration-ordering race completely.
Please either add the original or a representative failing fixture that reproduces #584 and passes on this branch, including the slow module/CDN timing if that is the real failure mode, or narrow the PR/issue language so this lands as a new recommended Three adapter registry without claiming to close the root issue completely.
Validation I ran locally:
bun run build:hyperframes-runtimebun run --cwd packages/core test -- src/runtime/adapters/three.test.ts-> 17 passedbun run --cwd packages/core test-> 611 passedbun run --cwd packages/core typecheck-> passedbun run lint:skills-> passedbunx oxlint/bunx oxfmt --checkon touched files -> passed- MP4 render fixtures under
/tmp/hf-pr598-reprowith pixel checks viaffmpeg
What
Add a
window.__hfThreeRendercallback registry to the three runtime adapter so HyperFrames seeks reliably reach Three.js scenes regardless of when the composition's render code is registered.Closes #584.
Why
Issue #584 reports that compositions following the
/threeskill pattern can freeze on their initial frame:window.__hfThreeTimeis updated correctly, but the user'shf-seekevent listener never fires.Looking at the bug from the runtime side, the failure mode is a registration-ordering race. The adapter's
seek()is called synchronously duringonDeterministicSeek, and the bootstrap path callsrunAdapters("discover")while the user's<script type="module">is still resolving its CDN imports. Any seek dispatched in that window has no listeners. With render mode running__hf.seek(t)per frame, a slow CDN fetch can starve the listener entirely —__hfThreeTimekeeps advancing but the scene never re-renders.The reporter's workaround (hooking
tl.eventCallback("onUpdate", ...)) only works for compositions that already have a GSAP timeline; the basic Three.js skill pattern has none. So the issue can't be papered over with docs alone.How
Mirror the registry convention already used by the
animejsandlottieadapters:window.__hfThreeRender: ((time: number) => void)[]seekand invokes each callback with the current time.hf-seekCustomEventkeeps firing, so existing compositions don't break.Compositions push their
renderAtfn into the array — late registrations still receive every subsequent seek, eliminating the race.Changes
packages/core/src/runtime/adapters/three.ts— invoke registry callbacks before dispatching the legacy event.packages/core/src/runtime/window.d.ts— type the new global.packages/core/src/runtime/adapters/three.test.ts— 10 new cases covering registry semantics, late registration, error isolation, malformed registries, ordering guarantee, and revert behavior. All previous tests retained.skills/three/SKILL.md— document the registry as the recommended pattern, keep the legacy event documented as a fallback. UpdateBasic PatternandAnimationMixer Pattern. Add aMultiple Layerssection.docs/concepts/frame-adapters.mdx— update the Three.js row in the seek-method table.Backward compatibility
Fully backward compatible. The
hf-seekevent still fires after every seek, so existing compositions usingaddEventListener("hf-seek", ...)keep working unchanged.window.__hfThreeTimeis also still set on every seek.Local gates
bunx vitest run packages/core/src/runtime/adapters/three.test.ts— 17/17 pass (7 existing + 10 new).bun run --filter @hyperframes/core testafterbun run build:hyperframes-runtime:modular— 611/611 pass across 43 test files.bunx oxlinton touched files — 0 warnings, 0 errors.bunx oxfmt --checkon touched files — clean.cd packages/core && bunx tsc --noEmit— clean (after the runtime artifact is built).bun run lint:skills— clean.Generated with assistance from Claude (Anthropic) — feedback welcome on scope or naming if you'd prefer a different convention.