From cea48a548ab18c729ecb5314a0ed78a7507d5acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 9 Oct 2026 17:11:11 +0000 Subject: [PATCH] fix(core): preview stops at the film's end when a scene loops forever (#5320) * fix(core): preview stops at the film's end when a scene loops forever * refactor(core): the loop length threshold lives with the composition length * test(core): pin the endless-loop length at the boundary and through the runtime * style(core): keep composition length comments under the file's share * refactor(studio): preview duration guard reads the core loop length limit * docs(core): length doc says a loop yields only to a floor or fallback --- .../src/runtime/compositionLength.test.ts | 20 +++++++++++++++++++ .../core/src/runtime/compositionLength.ts | 18 ++++++++++++----- .../runtime/init.compositionLength.test.ts | 8 ++++++++ packages/core/src/runtime/init.ts | 2 +- packages/core/src/runtime/timeline.ts | 6 +----- .../src/player/hooks/timelineSyncHydration.ts | 3 ++- 6 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime/compositionLength.test.ts b/packages/core/src/runtime/compositionLength.test.ts index fabaf93b0f..57ec301c3a 100644 --- a/packages/core/src/runtime/compositionLength.test.ts +++ b/packages/core/src/runtime/compositionLength.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { parseHTMLContent } from "../compiler/htmlDocument"; import { findRootCompositionElement, + LOOP_INFLATED_TIMELINE_SECONDS, MIN_VALID_TIMELINE_DURATION_SECONDS, readStaticCompositionMeta, resolveCompositionLengthSeconds, @@ -55,6 +56,25 @@ describe("resolveCompositionLengthSeconds", () => { ).toBe(2); }); + it("plays to the sub-composition floor, not a repeat:-1 timeline's 1e10 s", () => { + const endless = () => 1e10 + 2; + expect(resolveCompositionLengthSeconds({ ...base, timeline: endless, floors: () => [6] })).toBe( + 6, + ); + expect(resolveCompositionLengthSeconds({ ...base, timeline: endless, fallback: 5 })).toBe(5); + }); + + it("treats a timeline as loop-inflated from LOOP_INFLATED_TIMELINE_SECONDS on", () => { + const at = (seconds: number) => + resolveCompositionLengthSeconds({ ...base, timeline: () => seconds, floors: () => [6] }); + expect(at(LOOP_INFLATED_TIMELINE_SECONDS)).toBe(6); + expect(at(LOOP_INFLATED_TIMELINE_SECONDS - 1)).toBe(LOOP_INFLATED_TIMELINE_SECONDS - 1); + }); + + it("keeps a repeat:-1 timeline's length when nothing else gives one", () => { + expect(resolveCompositionLengthSeconds({ ...base, timeline: () => 1e10 })).toBe(1e10); + }); + it("derives the length only when nothing else gives one", () => { expect(resolveCompositionLengthSeconds({ ...base, derived: () => 2.5 })).toBe(2.5); expect(resolveCompositionLengthSeconds({ ...base, fallback: 9, derived: () => 2.5 })).toBe(9); diff --git a/packages/core/src/runtime/compositionLength.ts b/packages/core/src/runtime/compositionLength.ts index 7cae2572d7..cd8a14c1cc 100644 --- a/packages/core/src/runtime/compositionLength.ts +++ b/packages/core/src/runtime/compositionLength.ts @@ -10,6 +10,8 @@ import { createRuntimeState } from "./state"; export { findRootCompositionElement }; +export const LOOP_INFLATED_TIMELINE_SECONDS = 7200; + /** One frame at 60 fps: a timeline, floor or fallback this short or shorter is no length at all. */ export const MIN_VALID_TIMELINE_DURATION_SECONDS = 1 / 60; @@ -116,21 +118,27 @@ export type CompositionLengthInputs = { derived: () => number; }; -/** A declared root length is the length (a longer timeline is cut off); else the timeline, the - * floors and the fallback, whichever is longest; else the length derived from the clips. */ +/** A declared root length is the length; else the longest of timeline, floors and fallback (a + * loop-inflated timeline yields to a floor or fallback); else the length derived from the clips. */ export function resolveCompositionLengthSeconds(input: CompositionLengthInputs): number { if (input.declared !== null && Number.isFinite(input.declared) && input.declared > 0) { return input.declared; } - const timeline = aboveOneFrame(input.timeline()); + const rawTimeline = aboveOneFrame(input.timeline()); const floor = Math.max(0, ...input.floors().map((seconds) => seconds ?? 0)); const fallback = Number.isFinite(input.fallback) && input.fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? input.fallback : 0; + const floorOrFallback = Math.max(floor, fallback); + const loopInflated = + rawTimeline !== null && + rawTimeline >= LOOP_INFLATED_TIMELINE_SECONDS && + aboveOneFrame(floorOrFallback) !== null; + const timeline = loopInflated ? null : rawTimeline; let seconds: number; - if (timeline !== null) seconds = Math.max(timeline, floor, fallback); - else if (aboveOneFrame(floor) !== null) seconds = Math.max(floor, fallback); + if (timeline !== null) seconds = Math.max(timeline, floorOrFallback); + else if (aboveOneFrame(floor) !== null) seconds = floorOrFallback; else if (fallback > 0) seconds = fallback; else seconds = input.derived(); return seconds > 0 ? seconds : 0; diff --git a/packages/core/src/runtime/init.compositionLength.test.ts b/packages/core/src/runtime/init.compositionLength.test.ts index 88bf690b6f..be41a4dcaf 100644 --- a/packages/core/src/runtime/init.compositionLength.test.ts +++ b/packages/core/src/runtime/init.compositionLength.test.ts @@ -91,6 +91,14 @@ describe("runtime composition length and size", () => { expect(lengthOf(html, { main: createMockTimeline(2) })).toBe(6); }); + it("stops at the sub-composition's end when a tween repeats forever", () => { + const html = + `
` + + `
`; + // GSAP's length for a timeline holding a `repeat: -1` tween. + expect(lengthOf(html, { main: createMockTimeline(1e10 + 2) })).toBe(6); + }); + it("derives the length from the clips when nothing else gives one", () => { const html = `
` + diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 9e39df6d68..4ba6043977 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -57,12 +57,12 @@ import { createRuntimeState } from "./state"; import { collectRuntimeTimelinePayload, isRuntimeElementVisibleAt, - LOOP_INFLATED_TIMELINE_SECONDS, parseAuthoredTrack, } from "./timeline"; import { findRootCompositionElement, parseLayoutDimension } from "./compositionDimension"; import { resolveCompositionDuration } from "@hyperframes/parsers/composition-duration"; import { + LOOP_INFLATED_TIMELINE_SECONDS, MIN_VALID_TIMELINE_DURATION_SECONDS, readCompositionSize, resolveAuthoredCompositionFloorSeconds, diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 1de36921ee..36be7d827a 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -27,11 +27,7 @@ import { isSceneLikeCompositionId } from "../slideshow/index.js"; import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js"; import { runtimeProtocolMetadata } from "./protocol.js"; import { isElementNode, isMediaElement } from "./domRealm"; - -/** A root timeline this long is an endless loop, not a film: GSAP reports 1e10 s for `repeat: -1`. - * Studio's sanitizeDurationSeconds rejects the same length. Animations that simply end past the - * voiceover are real duration, and the runtime player already plays them. */ -export const LOOP_INFLATED_TIMELINE_SECONDS = 7200; +import { LOOP_INFLATED_TIMELINE_SECONDS } from "./compositionLength"; export function isRuntimeElementVisibleAt( rawNode: HTMLElement, diff --git a/packages/studio/src/player/hooks/timelineSyncHydration.ts b/packages/studio/src/player/hooks/timelineSyncHydration.ts index a92f858fb5..7f0dbc0bda 100644 --- a/packages/studio/src/player/hooks/timelineSyncHydration.ts +++ b/packages/studio/src/player/hooks/timelineSyncHydration.ts @@ -13,6 +13,7 @@ import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timel import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore"; import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; +import { LOOP_INFLATED_TIMELINE_SECONDS } from "@hyperframes/core/runtime/composition-length"; import type { ClipTree } from "@hyperframes/core/runtime/clipTree"; import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups"; import { groupInfoFor } from "../lib/timelineGroupInfo"; @@ -31,7 +32,7 @@ import { inspectStudioRuntimeMessage } from "../lib/runtimeProtocol"; /** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */ export function sanitizeDurationSeconds(value: number): number { - return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0; + return Number.isFinite(value) && value > 0 && value < LOOP_INFLATED_TIMELINE_SECONDS ? value : 0; } /**