Skip to content

fix(core): make three adapter seek robust against late listeners#598

Open
voidborne-d wants to merge 1 commit intoheygen-com:mainfrom
voidborne-d:fix/three-adapter-render-callback-registry
Open

fix(core): make three adapter seek robust against late listeners#598
voidborne-d wants to merge 1 commit intoheygen-com:mainfrom
voidborne-d:fix/three-adapter-render-callback-registry

Conversation

@voidborne-d
Copy link
Copy Markdown

What

Add a window.__hfThreeRender callback 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 /three skill pattern can freeze on their initial frame: window.__hfThreeTime is updated correctly, but the user's hf-seek event 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 during onDeterministicSeek, and the bootstrap path calls runAdapters("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 — __hfThreeTime keeps 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 animejs and lottie adapters:

  • window.__hfThreeRender: ((time: number) => void)[]
  • The three adapter iterates the array on every seek and invokes each callback with the current time.
  • Per-callback errors are swallowed so a broken layer can't starve sibling layers.
  • The legacy hf-seek CustomEvent keeps firing, so existing compositions don't break.

Compositions push their renderAt fn into the array — late registrations still receive every subsequent seek, eliminating the race.

// Before
window.addEventListener("hf-seek", (event) => renderAt(event.detail.time));

// After (recommended)
window.__hfThreeRender = window.__hfThreeRender || [];
window.__hfThreeRender.push(renderAt);

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. Update Basic Pattern and AnimationMixer Pattern. Add a Multiple Layers section.
  • docs/concepts/frame-adapters.mdx — update the Three.js row in the seek-method table.

Backward compatibility

Fully backward compatible. The hf-seek event still fires after every seek, so existing compositions using addEventListener("hf-seek", ...) keep working unchanged. window.__hfThreeTime is 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 test after bun run build:hyperframes-runtime:modular — 611/611 pass across 43 test files.
  • bunx oxlint on touched files — 0 warnings, 0 errors.
  • bunx oxfmt --check on 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.

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.
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

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

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-runtime
  • bun run --cwd packages/core test -- src/runtime/adapters/three.test.ts -> 17 passed
  • bun run --cwd packages/core test -> 611 passed
  • bun run --cwd packages/core typecheck -> passed
  • bun run lint:skills -> passed
  • bunx oxlint / bunx oxfmt --check on touched files -> passed
  • MP4 render fixtures under /tmp/hf-pr598-repro with pixel checks via ffmpeg

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.

bug: hf-seek events from Three.js adapter may not reach user listeners during Puppeteer render

2 participants