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
This commit is contained in:
Miguel Ángel
2026-10-09 17:11:11 +00:00
committed by GitHub
parent b059600083
commit cea48a548a
6 changed files with 45 additions and 12 deletions
@@ -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);
+13 -5
View File
@@ -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;
@@ -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 =
`<div data-composition-id="main" data-root="true">` +
`<div data-composition-id="scene" data-start="2" data-duration="4"></div></div>`;
// 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 =
`<div data-composition-id="main" data-root="true">` +
+1 -1
View File
@@ -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,
+1 -5
View File
@@ -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,
@@ -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;
}
/**