mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): guard timeline method calls for non-conformant objects (#1098)
* fix(core): guard timeline method calls for non-conformant objects User compositions can register timeline-like objects on window.__timeline where .duration is a number property (not a function) and .pause/.play may be missing entirely. The runtime player called these unconditionally, causing ~166 "duration is not a function" and ~38 "pause is not a function" errors per day. Add safeNum() and safeVoid() helpers that check typeof before calling, falling back to reading numbers as properties and silently skipping missing void methods. Applied consistently across all timeline method call sites in player.ts. * fix(core): add observability for non-conformant timeline properties
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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<string, unknown>)?.[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<string, unknown>)?.[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(),
|
||||
|
||||
Reference in New Issue
Block a user