diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index d066b51d3..13d1cadbe 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -488,6 +488,73 @@ describe("createRuntimePlayer", () => { }); }); + describe("tolerates non-conformant timeline objects", () => { + it("handles duration as a number property instead of a function", () => { + const timeline = { + play: vi.fn(), + pause: vi.fn(), + seek: vi.fn(), + totalTime: vi.fn(), + time: vi.fn(() => 2), + duration: 10, + add: vi.fn(), + paused: vi.fn(), + set: vi.fn(), + } as unknown as RuntimeTimelineLike; + const deps = createMockDeps(timeline); + const player = createRuntimePlayer(deps); + expect(player.getDuration()).toBe(10); + expect(() => player.play()).not.toThrow(); + }); + + it("handles missing pause method", () => { + const timeline = { + play: vi.fn(), + seek: vi.fn(), + time: vi.fn(() => 0), + duration: vi.fn(() => 10), + add: vi.fn(), + paused: vi.fn(), + set: vi.fn(), + } as unknown as RuntimeTimelineLike; + const deps = createMockDeps(timeline); + const player = createRuntimePlayer(deps); + expect(() => player.pause()).not.toThrow(); + expect(() => player.seek(3)).not.toThrow(); + }); + + it("handles missing play method", () => { + const timeline = { + pause: vi.fn(), + seek: vi.fn(), + time: vi.fn(() => 0), + duration: vi.fn(() => 10), + add: vi.fn(), + paused: vi.fn(), + set: vi.fn(), + } as unknown as RuntimeTimelineLike; + const deps = createMockDeps(timeline); + const player = createRuntimePlayer(deps); + expect(() => player.play()).not.toThrow(); + }); + + it("handles time as a number property instead of a function", () => { + const timeline = { + play: vi.fn(), + pause: vi.fn(), + seek: vi.fn(), + time: 5, + duration: vi.fn(() => 10), + add: vi.fn(), + paused: vi.fn(), + set: vi.fn(), + } as unknown as RuntimeTimelineLike; + const deps = createMockDeps(timeline); + const player = createRuntimePlayer(deps); + expect(player.getTime()).toBe(5); + }); + }); + describe("getters", () => { it("getTime returns timeline time", () => { const timeline = createMockTimeline({ time: 7.5 }); diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index 9d1ef263b..d28f3592a 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -2,6 +2,35 @@ import type { RuntimePlayer, RuntimeTimelineLike } from "./types"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { swallow } from "./diagnostics"; +/** + * Safely read a numeric value from a timeline property that may be either a + * function (conformant GSAP) or a bare number (user-authored timeline-like). + */ +function safeNum(obj: unknown, prop: string, fallback: number): number { + const val = (obj as Record)?.[prop]; + if (typeof val === "function") return Number(val.call(obj)) || fallback; + if (typeof val === "number" && Number.isFinite(val)) return val; + if (val !== undefined && val !== null) { + swallow("runtime.player.nonConformantNum", { prop, actual: typeof val }); + } + return fallback; +} + +/** + * Safely invoke a void method on a timeline. If the method is not a function + * (missing or overwritten with a non-callable value), silently skip. + */ +function safeVoid(obj: unknown, method: string): void { + const fn = (obj as Record)?.[method]; + if (typeof fn === "function") { + fn.call(obj); + return; + } + if (fn !== undefined) { + swallow("runtime.player.nonConformantVoid", { method, actual: typeof fn }); + } +} + type PlayerDeps = { getTimeline: () => RuntimeTimelineLike | null; setTimeline: (timeline: RuntimeTimelineLike | null) => void; @@ -52,11 +81,11 @@ function seekTimelineDeterministically( canonicalFps: number, ): number { const quantized = quantizeTimeToFrame(timeSeconds, canonicalFps); - timeline.pause(); + safeVoid(timeline, "pause"); if (typeof timeline.totalTime === "function") { timeline.totalTime(quantized, false); } else { - timeline.seek(quantized, false); + if (typeof timeline.seek === "function") timeline.seek(quantized, false); } return quantized; } @@ -69,7 +98,7 @@ function seekMasterAndSiblingTimelinesDeterministically( ): number { const rearmedSiblings: RuntimeTimelineLike[] = []; forEachSiblingTimeline(registry, master, (tl) => { - tl.play(); + safeVoid(tl, "play"); rearmedSiblings.push(tl); }); try { @@ -77,7 +106,7 @@ function seekMasterAndSiblingTimelinesDeterministically( } finally { for (const tl of rearmedSiblings) { try { - tl.pause(); + safeVoid(tl, "pause"); } catch (err) { // ignore sibling failures — one broken timeline shouldn't poison seek swallow("runtime.player.site2", err); @@ -91,7 +120,7 @@ function activateSiblingTimelines( master: RuntimeTimelineLike, ): void { forEachSiblingTimeline(registry, master, (tl) => { - tl.play(); + safeVoid(tl, "play"); }); } @@ -103,13 +132,13 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { if (!timeline || deps.getIsPlaying()) return; const safeDuration = Math.max( 0, - Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0, + Number(deps.getSafeDuration?.() ?? safeNum(timeline, "duration", 0)) || 0, ); if (safeDuration > 0) { - const currentTime = Math.max(0, Number(timeline.time()) || 0); + const currentTime = Math.max(0, safeNum(timeline, "time", 0)); if (currentTime >= safeDuration) { - timeline.pause(); - timeline.seek(0, false); + safeVoid(timeline, "pause"); + if (typeof timeline.seek === "function") timeline.seek(0, false); deps.onDeterministicSeek(0); deps.setIsPlaying(false); deps.onSyncMedia(0, false); @@ -119,10 +148,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { if (typeof timeline.timeScale === "function") { timeline.timeScale(deps.getPlaybackRate()); } - timeline.play(); + safeVoid(timeline, "play"); forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate()); - tl.play(); + safeVoid(tl, "play"); }); deps.onDeterministicPlay(); deps.setIsPlaying(true); @@ -132,11 +161,11 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { pause: () => { const timeline = deps.getTimeline(); if (!timeline) return; - timeline.pause(); + safeVoid(timeline, "pause"); forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { - tl.pause(); + safeVoid(tl, "pause"); }); - const time = Math.max(0, Number(timeline.time()) || 0); + const time = Math.max(0, safeNum(timeline, "time", 0)); deps.onDeterministicSeek(time); deps.onDeterministicPause(); deps.setIsPlaying(false); @@ -162,10 +191,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { if (typeof timeline.timeScale === "function") { timeline.timeScale(deps.getPlaybackRate()); } - timeline.play(); + safeVoid(timeline, "play"); forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => { if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate()); - tl.play(); + safeVoid(tl, "play"); }); deps.onDeterministicPlay(); deps.onShowNativeVideos(); @@ -199,8 +228,8 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { deps.onRenderFrameSeek(quantized); deps.onStatePost(true); }, - getTime: () => Number(deps.getTimeline()?.time() ?? 0), - getDuration: () => Number(deps.getTimeline()?.duration() ?? 0), + getTime: () => safeNum(deps.getTimeline(), "time", 0), + getDuration: () => safeNum(deps.getTimeline(), "duration", 0), isPlaying: () => deps.getIsPlaying(), setPlaybackRate: (rate: number) => deps.setPlaybackRate(rate), getPlaybackRate: () => deps.getPlaybackRate(),