Skip to content

fix(core): preview stops at the film's end when a scene loops forever - #5320

Merged
miguel-heygen merged 6 commits into
mainfrom
fix/preview-endless-loop-length
Oct 9, 2026
Merged

miguel-heygen merged 6 commits into
mainfrom
fix/preview-endless-loop-length

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What changes for a user

When a scene has a GSAP tween on repeat: -1 and the root declares no data-duration, Studio preview played past the end of the film. The counter read 00:08 / 00:06, the picture went blank, the button stayed on Pause and Play could not replay the film. Now preview stops at the last frame like any other film, and Play starts again from 0.

Render was already right (6.0 s output on the same fixture). Only preview was wrong.

Why

resolveCompositionLengthSeconds takes the longest of the timeline, the floors (sub-composition ends, media) and the fallback. GSAP reports about 1e10 s for a timeline that holds an infinite repeat, so the transport clock's end was 1e10 s. Studio's player paused at the real 6 s, but the runtime kept playing, so the two disagreed and the preview showed time past the end.

timeline.ts already treats a root timeline of LOOP_INFLATED_TIMELINE_SECONDS (7200 s) or longer as loop-inflated and uses the finite window instead. The clock length now follows the same rule: a loop-inflated timeline gives way to a floor or fallback when one exists. With neither, it keeps the timeline length, as before.

hyperframes lint already flags repeat: -1 without a root data-duration (gsap_infinite_repeat). This change makes preview agree with render when the author leaves it in.

7200 s is also the length Studio's player rejects as loop-inflated (sanitizeDurationSeconds); Studio now reads the same LOOP_INFLATED_TIMELINE_SECONDS instead of its own literal, so the two cannot drift. The constant now lives in compositionLength.ts (timeline.ts and init.ts import it), so the published runtime/composition-length entry does not load timeline.ts.

Limits

  • A sub-composition host with no declared length whose own timeline loops forever is itself an endless floor, so preview stays endless there. Studio's clip list has the same limit. hyperframes lint already reports repeat: -1 without a root data-duration.
  • With no root data-duration, finite root animation that runs past the last floor is cut at the floor (a 20 s tween plus a looping tween and a 6 s scene gives 6 s). This is deliberate. The other option was the loop-free end (__hf.animationEnd, 20 s there). Cutting at the floor keeps one rule for the film's length: Studio's clip list already ends the film at the last floor, so the transport and the clip list cannot disagree. A second rule would only serve a film the linter already flags: gsap_infinite_repeat tells the author to set a root data-duration whenever repeat: -1 is used, and that declared length always wins.
  • Render reads the same clock, so a real root timeline of 2 hours or more, with no data-duration and a shorter floor, would now preview and render at the floor. A root data-duration avoids it.
  • The clip list's rule (timeline.ts) and this one count slightly different floors (this one also counts adapter-inferred lengths).

Tests

Standalone on purpose: one root in the runtime clock, no other open change touches it.

  • compositionLength.test.ts: a floor or fallback wins over an endless timeline; with neither the timeline length stays; the boundary sits at exactly 7200 s. Exit 0, 23 passed, three runs in a row.
  • init.compositionLength.test.ts: the running runtime reports 6 s for a root timeline at GSAP's endless length with a 2 s + 4 s scene. Exit 0, 11 passed, three runs in a row.
  • Mutations: with the fix reverted the runtime test fails (10000000002 instead of 6), and so do two compositionLength.test.ts cases; >= to > fails the boundary test (7200 instead of 6).
  • timeline.test.ts: exit 0, 71 passed. Studio: timelineSyncHydration.test.ts exit 0, 6 passed; useTimelineSyncCallbacks.test.ts exit 0, 17 passed; Studio tsc --noEmit clean. oxfmt, oxlint and tsc --noEmit for core are clean.
  • Walked in Studio preview, headless, on a fixture built for this: a 2 s intro, then a 4 s scene with a yoyo repeat: -1 tween. Play from 4 s through the end, then press Play again.

Before

Preview runs past the end (00:08 / 00:06), blank picture, the button stays on Pause.

Before: preview runs past the end, 00:08 / 00:06, blank

After

Preview stops on the last frame at 00:06 / 00:06 and Play replays from 0.

After: preview stops at 00:06 / 00:06 on the last frame

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2059 (base branch 2059), smooth 1602 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)

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 9, 2026 08:53

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving at efb3e664. I found no blockers.

  • The fix holds. A loop-inflated root timeline (≥ 7200 s) now gives way to a floor or fallback, and keeps its length when there's neither. That's the same rule as the clip list's timelineLooksLoopInflated in timeline.ts. Every runtime caller of getSafeTimelineDurationSeconds passes a fallback of 0, so in the runtime only the floor half matters.
  • Moving the constant is safe. compositionLength.ts imports only compositionDimension, domRealm, playbackRate, startResolver and state, and none of those import ./timeline, so the new timeline.ts → compositionLength import adds no cycle. Studio's sanitizeDurationSeconds now reads the same constant through the published runtime/composition-length entry.
  • An endless floor stays endless, as before (max(1e10, ∞) was already ∞). That matches the limit stated in the PR.
  • Tests: core compositionLength, init.compositionLength and timeline pass: 105/105.
  • Mutants: 4 of 5 caught (revert the fix, >=→>, drop the floor-or-fallback guard, drop the fallback from floorOrFallback).
  • Survivor (low): in the no-timeline branch, seconds = floorOrFallback → seconds = floor isn't caught. No test has a floor below a larger fallback with no timeline. It can't affect the runtime, since every runtime caller passes a fallback of 0, but the published entry exposes fallback. A one-line case would pin it.
  • Nit: the doc comment on LOOP_INFLATED_TIMELINE_SECONDS (GSAP's 1e10 for repeat: -1, and Studio rejecting the same length) was dropped in the move. Worth keeping it next to the constant.
  • Merge and CI: merges cleanly with current main. No required check is red; Typecheck, Build and Render on windows were still running when I approved.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit cea48a5 Oct 9, 2026
232 of 233 checks passed
@miguel-heygen
miguel-heygen deleted the fix/preview-endless-loop-length branch October 9, 2026 17:32
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