Skip to content

Commit 27943f8

Browse files
committed
fix(core): cap the playable duration at the calculated document-level composition duration
The clock duration was max(gsap timeline, floors) with the declared duration only ever acting as a floor — so one ambient tween authored longer than the composition (a scene component's 20s background drift inside a 12s composition) stretched playback into a blank tail: 8s of empty frames after every clip had ended, the playhead pinned at the timeline edge, and play-at-end then restarting from 0. <html data-composition-duration> is machine-calculated from the content schedule by every emitter that stamps it, so it is now a CEILING on the playable duration. A composition HOST's data-duration deliberately stays floor-only: hand/agent-authored documents often declare a stale value there, and playing the full timeline remains the safer default (the 'keeps the timeline duration' test pins that).
1 parent f49f44f commit 27943f8

2 files changed

Lines changed: 52 additions & 0 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,28 @@ describe("initSandboxRuntimeModular", () => {
516516
expect(warned).not.toContain("Root timeline not bound");
517517
});
518518

519+
it("caps the playable duration at <html data-composition-duration>", () => {
520+
// The document-level duration is machine-calculated from the content
521+
// schedule, so it is a CEILING: one over-long ambient tween (a scene
522+
// component's 20s background drift) must not stretch playback into a
523+
// blank tail past the calculated end. (A composition HOST's declared
524+
// data-duration stays floor-only — see "keeps the timeline duration when
525+
// it exceeds the root's declared data-duration".)
526+
document.documentElement.setAttribute("data-composition-duration", "11.5");
527+
const root = document.createElement("div");
528+
root.className = "clip";
529+
root.setAttribute("data-root", "true");
530+
root.setAttribute("data-start", "0");
531+
document.body.appendChild(root);
532+
533+
window.__timelines = { main: createMockTimeline(20) };
534+
535+
initSandboxRuntimeModular();
536+
537+
expect(window.__player?.getDuration()).toBe(11.5);
538+
document.documentElement.removeAttribute("data-composition-duration");
539+
});
540+
519541
it("uses the shorter authored host window when the child timeline is longer", () => {
520542
const root = document.createElement("div");
521543
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,26 @@ export function initSandboxRuntimeModular(): void {
857857
return maxSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxSeconds : null;
858858
};
859859

860+
/**
861+
* The MACHINE-CALCULATED composition duration from
862+
* `<html data-composition-duration>`, used as a playable-duration CEILING.
863+
* Emitters that stamp this attribute (the generator, external compilers)
864+
* compute it from the content schedule, so a GSAP master running longer —
865+
* one scene component's 20s ambient background drift inside a 12s
866+
* composition is enough — must not stretch playback into a blank tail past
867+
* the calculated end. A composition HOST's `data-duration` is deliberately
868+
* NOT a ceiling: hand/agent-authored documents often declare a stale value
869+
* there, and playing the full timeline is the safer default (see the
870+
* authored-duration floor above, and the "keeps the timeline duration"
871+
* test).
872+
*/
873+
const resolveCalculatedCompositionDurationCeilingSeconds = (): number | null => {
874+
const declared = Number.parseFloat(
875+
document.documentElement.getAttribute("data-composition-duration") ?? "",
876+
);
877+
return Number.isFinite(declared) && declared > 0 ? declared : null;
878+
};
879+
860880
const getSafeTimelineDurationSeconds = (
861881
timeline: RuntimeTimelineLike | null,
862882
fallback = 0,
@@ -881,6 +901,16 @@ export function initSandboxRuntimeModular(): void {
881901
} else {
882902
safeDuration = fallbackDuration;
883903
}
904+
// The machine-calculated document-level duration is a CEILING. The floor
905+
// half already lives in resolveAuthoredCompositionDurationFloorSeconds (a
906+
// GSAP timeline ending slightly short must not shrink the playable
907+
// window); symmetrically, a timeline running LONGER than the calculated
908+
// schedule must not extend playback past it into frames where every clip
909+
// has ended.
910+
const declaredCeiling = resolveCalculatedCompositionDurationCeilingSeconds();
911+
if (isUsableTimelineDuration(declaredCeiling) && safeDuration > declaredCeiling) {
912+
safeDuration = declaredCeiling;
913+
}
884914
return safeDuration > 0 ? Math.max(0, safeDuration) : 0;
885915
};
886916

0 commit comments

Comments
 (0)