Skip to content

fix(core): exported scenes keep their frames when later scenes are added - #6035

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/export-frame-time
Oct 21, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/export-frame-time

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

What

Exporting a scene gives the same frames no matter how many scenes come after it. Before, adding a scene to a long project could change the export of untouched scenes: single frames from the wrong moment (a visible flash), one-frame shifts, and animations drawn at the wrong phase.

Why

The runtime keeps scene timelines paused at rest and unpauses them only across a root seek. GSAP counts only unpaused children in a timeline's length, and play() on a child does not re-measure the parent. When a scene after the playhead first renders during a seek (for example an overwrite: "auto" tween), it marks the root's length stale; the next length read happens while the scenes rest paused, so the root caches a length of 0. Every later root seek is then clamped to 0, so each scene is drawn once at its local time 0 before its own redraw. Stateful scenes keep that wrong state, which is the flash.

How

activateSiblingTimelines in packages/core/src/runtime/init.ts now re-measures the root right after it unpauses the scenes (shiftChildren(0) marks the length dirty, totalDuration() recomputes it with the scenes counted). It is the one place a render seek arms the scenes, so every capture path that drives window.__hf.seek (local render, chunked/distributed render, HDR compositing) gets it. It runs once per seek and only when there are scenes to arm.

Test plan

  • packages/core/src/runtime/init.rootLength.test.ts: three back-to-back scenes, one with an overwriting tween; renders frames 210 to 214 and checks the second scene is drawn only at its own times. Red without the fix, and red with only the shiftChildren line removed. Green 3 runs in a row.
  • Long real project (575 s, 24 fps), captured through the engine like a render: the frame that flashed (frame 10283) is dark on the old runtime bundle and matches its neighbours with the fix; frames 10281 to 10285 checked.
  • Comments follow CONTRIBUTING.md "Comments"

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed 0f3373fefee203b3fad4c9d0a95f4005aa364f3a; no earlier reviews or findings. No blocker found.

init.ts:4641-4650 remeasures after siblings are armed and before the root seek, fixing the cached-duration boundary itself. shiftChildren(0) invalidates GSAP's cached length without moving start times or labels. The no-sibling path remains unchanged and the methods remain optional for partial timeline implementations. Existing seekTimelineAndAdapters cleanup at init.ts:4728-4735 still pauses every borrowed sibling in finally, including a sibling whose play throws.

Audited: the full three-file delta and new regression; targeted surrounding root nesting/duration padding, renderSeek, duration clamp, standalone scene clock/seek and cleanup paths. This is not an end-to-end reread of the 5,600-line init module. All changes are confined to the activation seam and its structural type.

Executed: actual GSAP control reproduced duration 10 → cached 0 while children rest paused → still 0 after play → restored 10 after the added invalidation/read. Separate checks covered four scene counts across seven forward/backward/repeated seeks, preserving start times and labels. A fresh adversarial pass checked the actual activation helper, repeat/yoyo cases and cleanup, with no introduced failure found.

Limits: did not run the complete runtime Vitest regression or capture the reported 575-second project/flash frame; those remain author evidence. No full Chromium, HDR, distributed render or pixel proof. The added two-scene/three-scene tests correctly target each onUpdate time rather than merely asserting a total duration.

Verdict: APPROVE
Reasoning: Remeasuring the root after rearming repairs the stale-length seek clamp while preserving placement and borrowed-sibling cleanup. Focused GSAP behavior checks and an independent adversarial pass found no additional blocker.

— hf-review

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 152 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 (0)

Unstable (1)

  • seqplainkeys-none-pct-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed exact head 54ed97e2015cdeff34f1c6a1797fcbdebfbfe127 against my approved 0f3373fe (review 5601851719). The single follow-up commit changes only test formatting and the explanatory root-duration comment.

packages/core/src/runtime/init.ts:4642 retains the same invalidation/remeasurement calls after sibling activation. The two regression cases and their exact scene-time assertions remain intact; types.ts is byte-identical. I read the complete updated test and surrounding activation helper, and compared TypeScript-transformed JavaScript for all three PR files: each is identical to the approved revision after whitespace/comment removal. No runtime or test-assertion change, and no new blocker.

The prior focused GSAP checks and independent adversarial pass still apply to the unchanged implementation; I did not rerun the full runtime Vitest suite or the author's long-project capture. This supersedes my approval's commit association, preserving its stated validation limits.

Verdict: APPROVE
Reasoning: The follow-up is mechanical and preserves the previously reviewed root-length fix and regression assertions.

— hf-review

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 0160fbe Oct 21, 2026
79 checks passed
@miguel-heygen
miguel-heygen deleted the fix/export-frame-time branch October 21, 2026 16:08
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