From 8826d2d71d4883efad9e7e9f9036adcf229d89a2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 16 Jul 2026 01:58:39 -0700 Subject: [PATCH] fix(runtime): tolerate registry timelines without pause() in interactive transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window.__timelines entry is authored content and may be a partial RuntimeTimelineLike (duration/seek only, no pause). Timeline resolution is deliberately permissive — duration-based — and such compositions render fine, because the render path only seeks. But every interactive transport path (play/pause/seek, bind, rebind-tick, boot) called capturedTimeline.pause() unguarded, crashing studio playback with 'tl.pause is not a function' — the top recurring studio:unhandled_error in telemetry across versions 0.6.121 through 0.7.59 (~150-175/day). Guard all pause sites through one helper (typeof check + swallow, plus a once-per-page timeline_missing_pause analytics event so composition authors can find the partial timeline), matching the safeVoid pattern player.ts already uses. In the rebind restore path, pause is guarded separately so a missing pause() no longer aborts the seek/play restore behind it in the same try/catch. Co-Authored-By: Claude Fable 5 --- packages/core/src/runtime/analytics.ts | 3 +- packages/core/src/runtime/init.test.ts | 31 +++++++++++++++ packages/core/src/runtime/init.ts | 53 ++++++++++++++++++++------ 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/core/src/runtime/analytics.ts b/packages/core/src/runtime/analytics.ts index 795c20e1f..08f3a24b7 100644 --- a/packages/core/src/runtime/analytics.ts +++ b/packages/core/src/runtime/analytics.ts @@ -13,7 +13,8 @@ export type RuntimeAnalyticsEvent = | "composition_seeked" | "composition_ended" | "element_picked" - | "position_edit_fold_skipped"; + | "position_edit_fold_skipped" + | "timeline_missing_pause"; export type RuntimeAnalyticsProperties = Record; diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e8d56a151..c83fa4d30 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2035,4 +2035,35 @@ describe("initSandboxRuntimeModular", () => { expect(footer.style.left).toBe(""); }); }); + describe("partial registry timelines", () => { + it("survives play/pause/seek when the sole registered timeline lacks pause()", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + // An authored composition can register a PARTIAL timeline — duration/seek + // only. It renders fine (the render path never pauses), so the interactive + // transport must tolerate the missing pause() instead of throwing + // "tl.pause is not a function" (top recurring studio unhandled error). + const partial = createMockTimeline(10) as RuntimeTimelineLike & { pause?: unknown }; + delete partial.pause; + window.__timelines = { main: partial as RuntimeTimelineLike }; + + initSandboxRuntimeModular(); + const player = window.__player; + expect(player).toBeDefined(); + + expect(() => { + player?.play(); + player?.pause(); + player?.seek(1); + player?.renderSeek(2); + }).not.toThrow(); + }); + }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 35f93e56a..c6f606012 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -53,6 +53,32 @@ import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; +/** + * A `window.__timelines` entry is authored content and may be a PARTIAL + * RuntimeTimelineLike — e.g. duration/seek only, no `pause()`. Such + * compositions render fine (the render path only seeks and never pauses), so + * timeline resolution stays permissive by design; the interactive transport + * must not crash on the missing method (top recurring studio:unhandled_error: + * "E.pause is not a function"). One analytics event per page so the + * composition author can find the partial timeline. + */ +let warnedTimelineMissingPause = false; +function pauseTimelineIfPossible(tl: RuntimeTimelineLike | null | undefined): void { + if (!tl) return; + if (typeof tl.pause !== "function") { + if (!warnedTimelineMissingPause) { + warnedTimelineMissingPause = true; + emitAnalyticsEvent("timeline_missing_pause", {}); + } + return; + } + try { + tl.pause(); + } catch (err) { + swallow("runtime.timeline.pause", err); + } +} + type ExportRenderFpsResolution = { fps: number | null; source: "render-options" | "default" | "unknown"; @@ -1210,7 +1236,7 @@ export function initSandboxRuntimeModular(): void { if (typeof state.capturedTimeline.progress === "function") { state.capturedTimeline.progress(1, true); state.capturedTimeline.progress(0, false); - state.capturedTimeline.pause(); + pauseTimelineIfPossible(state.capturedTimeline); } } if (boundDuration > 0) { @@ -1232,7 +1258,7 @@ export function initSandboxRuntimeModular(): void { } const seekTime = Math.max(0, state.currentTime || 0); state.capturedTimeline.totalTime(seekTime, false); - state.capturedTimeline.pause(); + pauseTimelineIfPossible(state.capturedTimeline); } // GSAP bakes the CSS `translate` into style.transform on seek. @@ -1536,9 +1562,13 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.timeScale(state.playbackRate); } try { - state.capturedTimeline.pause(); - state.capturedTimeline.seek(previousTime, false); - if (wasPlaying) { + // pause guarded separately: a PARTIAL timeline without pause() must not + // abort the seek/play restore below (the catch would swallow them too). + pauseTimelineIfPossible(state.capturedTimeline); + if (typeof state.capturedTimeline.seek === "function") { + state.capturedTimeline.seek(previousTime, false); + } + if (wasPlaying && typeof state.capturedTimeline.play === "function") { state.capturedTimeline.play(); } } catch (err) { @@ -2135,7 +2165,7 @@ export function initSandboxRuntimeModular(): void { const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0); if (declaredDur > 0) clock.setDuration(declaredDur); } - if (tl) tl.pause(); + pauseTimelineIfPossible(tl); if (!clock.play()) return; state.isPlaying = true; state.mediaForceSyncNextTick = true; @@ -2161,7 +2191,7 @@ export function initSandboxRuntimeModular(): void { state.mediaForceSyncNextTick = true; hardSyncAllMedia(state.currentTime); const tl = state.capturedTimeline; - if (tl) tl.pause(); + pauseTimelineIfPossible(tl); runAdapters("pause"); syncMediaForCurrentState(); colorGrading.redraw(); @@ -2181,7 +2211,7 @@ export function initSandboxRuntimeModular(): void { state.isPlaying = false; state.mediaForceSyncNextTick = true; const tl = state.capturedTimeline; - if (tl) tl.pause(); + pauseTimelineIfPossible(tl); seekTimelineAndAdapters(state.currentTime); runAdapters("pause"); if (options?.keepPlaying && wasPlaying) { @@ -2549,7 +2579,8 @@ export function initSandboxRuntimeModular(): void { ) => { try { const suppressEvents = options?.suppressEvents === true; - timeline.pause(); + // Guarded: a partial timeline without pause() must still get its seek. + pauseTimelineIfPossible(timeline); if (typeof timeline.totalTime === "function") { timeline.totalTime(timeSeconds, suppressEvents); } else { @@ -2783,7 +2814,7 @@ export function initSandboxRuntimeModular(): void { player._timeline = state.capturedTimeline; } if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) { - state.capturedTimeline.pause(); + pauseTimelineIfPossible(state.capturedTimeline); } const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); if (dur > 0) clock.setDuration(dur); @@ -2996,7 +3027,7 @@ export function initSandboxRuntimeModular(): void { if (state.capturedTimeline) { const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); if (dur > 0) clock.setDuration(dur); - state.capturedTimeline.pause(); + pauseTimelineIfPossible(state.capturedTimeline); } installPositionEditsSeekReapply(window as Window & typeof globalThis);