mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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">` +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user