fix(core): remove 1800s hard cap on timeline duration that silently truncated long compositions (#1114)

The runtime had a maxTimelineDurationSeconds field defaulting to 1800
(30 minutes) that clamped the TransportClock duration. Any seek beyond
this cap was silently clamped, so GSAP tweens starting past ~1700s
never received their totalTime() call and stayed at their pre-tween
state (e.g. opacity:0).

The data-duration attribute is the authored source of truth. The loop-
inflation guard (timelineLooksLoopInflated) already handles the infinite
repeat:-1 case this cap was meant to protect against.

Closes #1107
This commit is contained in:
Miguel Ángel
2026-05-28 16:26:40 -04:00
committed by GitHub
parent 5245e19062
commit e0cb8fcee3
5 changed files with 7 additions and 25 deletions
+1 -3
View File
@@ -538,8 +538,7 @@ export function initSandboxRuntimeModular(): void {
} else {
safeDuration = fallbackDuration;
}
const hardDurationCap = Math.max(1, Number(state.maxTimelineDurationSeconds) || 1800);
return safeDuration > 0 ? Math.max(0, Math.min(safeDuration, hardDurationCap)) : 0;
return safeDuration > 0 ? Math.max(0, safeDuration) : 0;
};
const resolveRootTimelineFromDocument = (): TimelineResolution => {
@@ -1423,7 +1422,6 @@ export function initSandboxRuntimeModular(): void {
bindRootTimelineIfAvailable();
const payload = collectRuntimeTimelinePayload({
canonicalFps: state.canonicalFps,
maxTimelineDurationSeconds: state.maxTimelineDurationSeconds,
});
window.__clipManifest = payload;
postRuntimeMessage(payload);
-1
View File
@@ -12,7 +12,6 @@ describe("createRuntimeState", () => {
expect(state.capturedTimeline).toBeNull();
expect(state.rafId).toBeNull();
expect(state.tornDown).toBe(false);
expect(state.maxTimelineDurationSeconds).toBe(1800);
expect(state.parityModeEnabled).toBe(true);
});
-2
View File
@@ -72,7 +72,6 @@ export type RuntimeState = {
cachedVideoClips: RuntimeMediaClip[];
cachedMediaTimelineDurationSeconds: number;
tornDown: boolean;
maxTimelineDurationSeconds: number;
nativeVisualWatchdogTick: number;
/**
* Single-clock transport. The sole time authority — GSAP is always
@@ -115,7 +114,6 @@ export function createRuntimeState(): RuntimeState {
cachedVideoClips: [],
cachedMediaTimelineDurationSeconds: 0,
tornDown: false,
maxTimelineDurationSeconds: 1800,
nativeVisualWatchdogTick: 0,
transportClock: null,
transportRafId: null,
+4 -7
View File
@@ -7,7 +7,7 @@ describe("collectRuntimeTimelinePayload", () => {
delete (window as any).__timelines;
});
const defaultParams = { canonicalFps: 30, maxTimelineDurationSeconds: 1800 };
const defaultParams = { canonicalFps: 30 };
it("returns minimal payload for empty document", () => {
const result = collectRuntimeTimelinePayload(defaultParams);
@@ -214,7 +214,7 @@ describe("collectRuntimeTimelinePayload", () => {
expect(result.durationInFrames).toBe(210); // 7s * 30fps
});
it("clamps duration to maxTimelineDurationSeconds", () => {
it("respects long composition durations without capping", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-duration", "5000");
@@ -225,11 +225,8 @@ describe("collectRuntimeTimelinePayload", () => {
clip.setAttribute("data-duration", "5000");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload({
canonicalFps: 30,
maxTimelineDurationSeconds: 60,
});
expect(result.durationInFrames).toBeLessThanOrEqual(60 * 30);
const result = collectRuntimeTimelinePayload({ canonicalFps: 30 });
expect(result.durationInFrames).toBe(5000 * 30);
});
it("skips script/style/meta nodes", () => {
+2 -12
View File
@@ -176,7 +176,6 @@ function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"]
export function collectRuntimeTimelinePayload(params: {
canonicalFps: number;
maxTimelineDurationSeconds: number;
}): RuntimeTimelineMessage {
const runtimeWindow = window as Window & {
__timelines?: Record<string, RuntimeTimelineLike | undefined>;
@@ -347,10 +346,7 @@ export function collectRuntimeTimelinePayload(params: {
mediaWindowDurationCandidate,
compositionWindowDurationCandidate,
));
const rootCompositionDuration =
preferredRootDuration != null
? Math.min(preferredRootDuration, params.maxTimelineDurationSeconds)
: null;
const rootCompositionDuration = preferredRootDuration ?? null;
const rootCompositionEnd =
rootCompositionDuration != null ? rootCompositionStart + rootCompositionDuration : null;
const timelineWindowEnd =
@@ -666,13 +662,7 @@ export function collectRuntimeTimelinePayload(params: {
// hide structural/background tracks from the timeline UI; if we collapse the
// payload duration down to the last visible clip end, the controls jump even
// though playback still runs for the full authored root duration.
const safeDuration = Math.max(
1,
Math.min(
Math.max(maxEnd || 1, rootCompositionDuration ?? 0),
params.maxTimelineDurationSeconds,
),
);
const safeDuration = Math.max(1, maxEnd || 1, rootCompositionDuration ?? 0);
const shouldEmitNonDeterministicInf = timelineLooksLoopInflated && attrDurationCandidate == null;
const durationInFrames = shouldEmitNonDeterministicInf
? Number.POSITIVE_INFINITY