fix(player+core): correctly render and pause nested compositions#359
Merged
jrusso1020 merged 2 commits intomainfrom Apr 21, 2026
Merged
Conversation
Compositions that use `data-composition-src` on child elements require the HyperFrames runtime to load those scenes — there is no way for the iframe to render without it. The existing probe loop delayed runtime injection behind a 5-tick attempts gate so the adapter path could try to resolve a timeline first. For nested compositions that race lost: a composition like the `product-promo` registry example registers an inline pre-runtime GSAP timeline at `window.__timelines["main"]` (covering only a partial duration, e.g. 14s of a 20s master) while the iframe document loads. The probe's adapter check finds that timeline and locks the player into a "ready" state against it — which short-circuits the attempts gate and the runtime never gets injected. The iframe ends up blank because the runtime is what would have loaded the child scenes via `data-composition-src`. This change splits the injection decision into a pure helper, `shouldInjectRuntime(state)`, and treats nested compositions as "inject immediately, skip the gate." Self-contained GSAP-only compositions retain the 5-tick grace period so the adapter path keeps first shot for them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pausing or playing the master timeline only called `.pause()` / `.play()` on `state.capturedTimeline` — the single adapter-selected timeline. In a nested composition (a master with `data-composition-src` children), each scene's own timeline is registered as a sibling in `window.__timelines`, so they would keep advancing after the user clicked pause. The player UI froze at the paused time while the visual content continued to animate, eventually finishing all scene-level animations and landing on an empty end-state. Wire `window.__timelines` into the runtime player via a new `getTimelineRegistry` dep, iterate the registry on play/pause, and forward `timeScale` to siblings when play() starts so a changed playback-rate applies uniformly. Covered by 7 new unit tests in player.test.ts, including the identity- equality check (don't double-invoke the master), playbackRate propagation, a broken-sibling swallow, and a back-compat case with no registry supplied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
miguel-heygen
approved these changes
Apr 21, 2026
6 tasks
jrusso1020
added a commit
that referenced
this pull request
Apr 21, 2026
Follow-up to #359 for the async-scene-loading path (compositions served as raw static files with `data-composition-src` children, as opposed to the `bundleToSingleHtml` studio path). ## Problem In the async-scene path, scenes visibly animate on page load while the master stays paused at 0:00 — the "autoplay on load" bug. Traced via a `paused`-setter proxy on each registered timeline: on init, each scene's `_ts` flipped 0→1 inside `ensureChildCandidatesActive` in `init.ts`, calling `timeline.paused(false)` on every child candidate. That helper predates #359 and was the only way a user-click Play would animate scenes — back when play didn't propagate through the registry. After #359, `createRuntimePlayer.play` already iterates every registry entry and un-pauses each, so the init-time un-pause is redundant for user-driven playback. It still matters for the producer's render path, though: there the master's `totalTime` cascade is the only driver (no GSAP ticker — virtual time), and a paused child won't re-render on cascade, so every producer regression baseline depends on children being un-paused at init time. ## Fix Scope the un-pause to render mode, signalled by `window.__HF_VIRTUAL_TIME__` (set by the producer's render-mode bootstrap script). Preview mode leaves children paused — no autoplay; play/pause still propagate via the registry iteration added in #359. Reproduced + verified locally with `Dockerfile.test`: - Before this fix, `overlay-montage-prod`, `style-4-prod`, `style-7-prod`, `style-8-prod`, `style-9-prod`, `style-17-prod`, `style-18-prod` all fail PSNR with 15-30 failed frames (scenes rendering at wrong times). - After this fix, all four run to 0/100 failed frames. ## Known limitation (follow-up) In preview mode, a scrub-back after a full playthrough can still leave async scenes parked at their end-state, because GSAP's cascade skips rendering paused children and the async loader isn't fully parenting scenes to the master. Tracked for a separate fix to the async composition loader (see `addMissingChildCandidatesToRootTimeline` in `init.ts`). ## Files - `packages/core/src/runtime/init.ts` — add `isRenderMode` guard around `ensureChildCandidatesActive` - `packages/core/src/runtime/player.ts` — unchanged from #359's registry iteration on play/pause - `packages/core/src/runtime/player.test.ts` — 2 new tests documenting that `seek`/`renderSeek` intentionally do NOT iterate the registry ## Testing - [x] `cd packages/core && bunx vitest run` — 490/490 pass (+2 new) - [x] `cd packages/player && bunx vitest run` — 35/35 pass (unchanged) - [x] `bun run typecheck` in core + player — clean - [x] `bunx oxlint` / `bunx oxfmt --check` clean across changed files - [x] `docker run hyperframes-producer:test style-17-prod` — 0/100 frames fail - [x] `docker run hyperframes-producer:test style-7-prod` — 0/100 frames fail - [x] `docker run hyperframes-producer:test style-4-prod` — 0/100 frames fail - [x] `docker run hyperframes-producer:test overlay-montage-prod` — 0/100 frames fail - [x] Live Vercel deploy verified: scenes stay paused at page load (autoplay fixed); play/pause propagates as expected Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jrusso1020
added a commit
to heygen-com/hyperframes-vercel-template
that referenced
this pull request
Apr 23, 2026
Preparing the template for public release.
**Code cleanup (reuse / quality / efficiency)**
- Add runSandboxCommand helper — DRYs five runCommand+exit-code-throw
sites across lib/sandbox.ts and scripts/create-snapshot.ts.
- Drop redundant module-level cachedFiles in /api/render (one-shot call
in a serverless invocation; cache adds stale-state surface for no win).
- Import PREVIEW_COMPOSITION_DIR from lib/preview in /api/render rather
than redefining the same path there.
- Remove six noisy [sandbox] console.log lines in the render hot path.
- Memoize getBundledPreviewHtml — was spawning a tsx subprocess per
/api/preview hit; result is immutable per deployment.
- Surface the previously silent catch {} in the bundler as a console.warn
so users can debug a failed bundle instead of getting raw index.html
with no explanation.
- Replace sync existsSync+readFileSync in /api/runtime.js with an async,
module-memoized loader; also removes two unreachable branches (the
HYPERFRAMES_RUNTIME_URL string comparisons and the redirect path were
dead, since that constant is hardcoded to "/api/runtime.js").
- Export a single PREVIEW_RUNTIME_ALIASES const (shared home for the
two hyperframe-runtime filenames).
- Drop the unused SNAPSHOT_SETUP_TIMEOUT_MS re-export.
**Dead content**
- Delete public/compositions/product-promo/ — unreferenced since the
template switched to ui-3d-reveal; only stale README/test references
remained.
**Docs**
- README: replace all product-promo references with ui-3d-reveal and
simplify "Swapping the composition" to reflect the single
PREVIEW_COMPOSITION_DIR constant in lib/preview.ts.
- README: remove "Nested compositions need the runtime script"
workaround section — heygen-com/hyperframes#359 shipped in
@hyperframes/player 0.4.14 so the CDN script tag is no longer needed.
**Tests**
- Rewrite lib/preview.test.ts to match the ui-3d-reveal composition;
drops four stale assertions tied to product-promo's scene IDs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jrusso1020
added a commit
to heygen-com/hyperframes-vercel-template
that referenced
this pull request
Apr 23, 2026
…eanup
Consolidates the test-preview-deploy branch work plus a full-repo simplify
pass in preparation for making this template public.
**Dependencies**
- @hyperframes/core + @hyperframes/player → ^0.4.14 (latest)
- Sandbox continues to pull hyperframes@latest at snapshot-build time
**Demo composition**
- Replace product-promo with ui-3d-reveal (simpler, single-scene,
showcases the format without the sub-composition lazy-load path)
**Code cleanup**
- Add runSandboxCommand helper — DRYs five runCommand+exit-code-throw
sites across lib/sandbox.ts and scripts/create-snapshot.ts
- Drop the redundant module-level cachedFiles in /api/render (one-shot
call in a serverless invocation; cache added stale-state surface for
no win)
- Import PREVIEW_COMPOSITION_DIR from lib/preview in /api/render rather
than redefining the same path
- Remove six noisy [sandbox] console.log lines in the render hot path
- Memoize getBundledPreviewHtml — was spawning a tsx subprocess per
/api/preview hit; result is immutable per deployment
- Surface the previously silent catch {} in the bundler as a console.warn
so template users can debug bundle failures
- Replace sync existsSync+readFileSync in /api/runtime.js with an async,
module-memoized loader; also removes two unreachable branches (the
HYPERFRAMES_RUNTIME_URL string comparisons and the redirect path were
dead, since that constant is hardcoded to "/api/runtime.js")
- Export a single PREVIEW_RUNTIME_ALIASES const as the shared home for
the two hyperframe-runtime filenames
- Drop unused SNAPSHOT_SETUP_TIMEOUT_MS re-export
**Docs**
- README: update all product-promo references to ui-3d-reveal and
simplify "Swapping the composition" to reflect the single
PREVIEW_COMPOSITION_DIR constant
- README: remove "Nested compositions need the runtime script" workaround
section — heygen-com/hyperframes#359 shipped in @hyperframes/player
0.4.14, so the CDN script tag is no longer needed
**Tests**
- Rewrite lib/preview.test.ts assertions to match ui-3d-reveal; all 8
tests pass (4 were previously failing against the now-deleted
product-promo composition)
Verified end-to-end against a Vercel preview deploy — /api/render
returned a valid 1920x1080 H.264 MP4 in ~49s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related fixes for nested-composition playback in
<hyperframes-player>. Both surface when a composition usesdata-composition-srcto lazy-load child scenes (e.g.registry/examples/product-promo) and is served through the async/non-bundled path — i.e. raw static files or a custom server that doesn't run the composition throughbundleToSingleHtml.The studio preview (
hyperframes preview) already bundles compositions viabundleToSingleHtmlbefore serving, so it masks both bugs. They're latent for studio users but visible to anyone embedding the player against raw composition HTML (static hosts, CDN embeds, the upcoming HyperFrames Vercel template, etc.).1.
fix(player): inject runtime immediately for nested compositionsProblem
<hyperframes-player>renders an empty black iframe when loading any composition that usesdata-composition-srcon child elements. The composition is structured correctly and renders perfectly viahyperframes renderserver-side — but the browser preview is blank.Root cause
The probe loop in
hyperframes-player.tshas two paths to "ready":hasTimelines && !hasRuntime && attempts >= 5window.__timelinesorwindow.__playerifadapter.getDuration() > 0For nested compositions in the async path, path (2) wins the race. The composition registers inline pre-runtime GSAP timelines at
window.__timelines[\"main\"]while the iframe document is loading (typically covering only a partial duration — 14s of a 20s master, forproduct-promo). On the very first probe tick — long before attempts reaches 5 — the adapter check finds that timeline, treats it as source of truth, and locks the player into a broken "ready" state. The runtime never gets injected, so scenes declared viadata-composition-srcnever load. The iframe stays blank.Fix
Split the injection decision into a pure helper (
shouldInjectRuntime(state)) and treat compositions withdata-composition-srcchildren as "inject immediately, skip the attempts gate." Self-contained GSAP-only compositions retain the 5-tick grace period so the adapter path keeps first shot for them (no regression).2.
fix(core): propagate play/pause to all sibling timelinesProblem
Even after the runtime loads, clicking pause on the player only freezes the master timeline (which, for
product-promo, is the partial inlinemainTlplus captions). The per-scene timelines keep playing, and after a few seconds the composition ends up in an empty gray end-state while the player UI still shows the paused time.Root cause
packages/core/src/runtime/player.ts:67-78—player.pause()only calls.pause()on the single adapter-selected timeline (state.capturedTimeline). In the async scene-loading path, each scene's timeline is registered as a sibling inwindow.__timelines(e.g.scene1-logo-intro,scene2-4-canvas,scene5-logo-outro), driven independently off GSAP's ticker. Pausing master leaves them free-running. Same issue symmetrically onplay().Bundled compositions happen to avoid this because the bundled pipeline nests everything synchronously, and nested children inherit their parent's paused state.
Fix
Wire
window.__timelinesinto the runtime player via a new optionalgetTimelineRegistrydep, iterate the registry on play/pause, and forwardtimeScaleto siblings on play so a changed playback-rate applies uniformly. Identity-equality check ensures the master isn't double-invoked; a try/catch isolates one broken sibling from breaking the whole pause. The change is a no-op on the bundled path (siblings there are already paused as nested children).Files
packages/player/src/shouldInjectRuntime.ts— new pure decision helperpackages/player/src/shouldInjectRuntime.test.ts— 8 unit testspackages/player/src/hyperframes-player.ts— computehasNestedCompositions, call the helperpackages/core/src/runtime/player.ts— addgetTimelineRegistrytoPlayerDeps, iterate on play/pausepackages/core/src/runtime/player.test.ts— 7 new unit tests covering propagation, playbackRate, identity, errors, back-compat, undefined entriespackages/core/src/runtime/init.ts— wirewindow.__timelinesinto the depTesting
cd packages/player && bunx vitest run— 35/35 pass (8 new + 27 existing)cd packages/core && bunx vitest run— 488/488 pass (7 new + 481 existing)bunx oxlintclean across all changed filesbunx oxfmt --checkcleanbun run typecheckclean in both packagesproduct-promocomposition: preview at t=0 matches rendered MP4 frame 0, preview at t=3s matches frame 3. Pause freezes the full composition, not just the master timeline.npx hyperframes@latest preview registry/examples/product-promo(which goes through the bundled path) pauses correctly — consistent with this being a bug in the async-scene-loading path only.