Skip to content

fix(player+core): correctly render and pause nested compositions#359

Merged
jrusso1020 merged 2 commits intomainfrom
fix/player-inject-runtime-for-nested-compositions
Apr 21, 2026
Merged

fix(player+core): correctly render and pause nested compositions#359
jrusso1020 merged 2 commits intomainfrom
fix/player-inject-runtime-for-nested-compositions

Conversation

@jrusso1020
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 commented Apr 21, 2026

Two related fixes for nested-composition playback in <hyperframes-player>. Both surface when a composition uses data-composition-src to 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 through bundleToSingleHtml.

The studio preview (hyperframes preview) already bundles compositions via bundleToSingleHtml before 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 compositions

Problem

<hyperframes-player> renders an empty black iframe when loading any composition that uses data-composition-src on child elements. The composition is structured correctly and renders perfectly via hyperframes render server-side — but the browser preview is blank.

Root cause

The probe loop in hyperframes-player.ts has two paths to "ready":

  1. Inject the runtime if hasTimelines && !hasRuntime && attempts >= 5
  2. Use an adapter built from whatever's at window.__timelines or window.__player if adapter.getDuration() > 0

For 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, for product-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 via data-composition-src never load. The iframe stays blank.

Fix

Split the injection decision into a pure helper (shouldInjectRuntime(state)) and treat compositions with data-composition-src children 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 timelines

Problem

Even after the runtime loads, clicking pause on the player only freezes the master timeline (which, for product-promo, is the partial inline mainTl plus 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-78player.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 in window.__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 on play().

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.__timelines into the runtime player via a new optional getTimelineRegistry dep, iterate the registry on play/pause, and forward timeScale to 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 helper
  • packages/player/src/shouldInjectRuntime.test.ts — 8 unit tests
  • packages/player/src/hyperframes-player.ts — compute hasNestedCompositions, call the helper
  • packages/core/src/runtime/player.ts — add getTimelineRegistry to PlayerDeps, iterate on play/pause
  • packages/core/src/runtime/player.test.ts — 7 new unit tests covering propagation, playbackRate, identity, errors, back-compat, undefined entries
  • packages/core/src/runtime/init.ts — wire window.__timelines into the dep

Testing

  • 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 oxlint clean across all changed files
  • bunx oxfmt --check clean
  • bun run typecheck clean in both packages
  • End-to-end verified via Playwright against a live Vercel deploy of a Next.js template using the unmodified product-promo composition: 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.
  • Manually verified the bug in the release: running 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.

jrusso1020 and others added 2 commits April 21, 2026 04:27
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>
@jrusso1020 jrusso1020 changed the title fix(player): inject runtime immediately for nested compositions fix(player+core): correctly render and pause nested compositions Apr 21, 2026
@jrusso1020 jrusso1020 merged commit e72bcfa into main Apr 21, 2026
25 checks passed
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>
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.

2 participants