mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): enforce strict runtime safety
This commit is contained in:
@@ -37,7 +37,7 @@ export function createCssAdapter(params?: {
|
||||
animation: Animation,
|
||||
startSeconds: number,
|
||||
): { endSeconds?: number; unbounded?: true } => {
|
||||
let timing: { endTime?: number | string } | null = null;
|
||||
let timing: ComputedEffectTiming | null = null;
|
||||
try {
|
||||
timing = animation.effect?.getComputedTiming?.() ?? null;
|
||||
} catch (err) {
|
||||
|
||||
@@ -100,7 +100,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
value: original,
|
||||
configurable: true,
|
||||
});
|
||||
const wrappedAnimate = function (...args: Parameters<Element["animate"]>) {
|
||||
const wrappedAnimate = function (this: Element, ...args: Parameters<Element["animate"]>) {
|
||||
const animation = original.apply(this, args);
|
||||
trackAnimation(animation, lastSeekTimeMs);
|
||||
return animation;
|
||||
@@ -126,7 +126,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
const inferAnimationEndSeconds = (
|
||||
animation: Animation,
|
||||
): { endSeconds?: number; unbounded?: true } => {
|
||||
let timing: { endTime?: number | string } | null = null;
|
||||
let timing: ComputedEffectTiming | null = null;
|
||||
try {
|
||||
timing = animation.effect?.getComputedTiming?.() ?? null;
|
||||
} catch (err) {
|
||||
|
||||
@@ -60,7 +60,8 @@ function isSafeMediaUrl(url: string): boolean {
|
||||
const normalized = url.replace(/[\u0000-\u0020]/g, "");
|
||||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
|
||||
if (!scheme) return true;
|
||||
const proto = scheme[1].toLowerCase();
|
||||
const proto = scheme[1]?.toLowerCase();
|
||||
if (!proto) return false;
|
||||
if (proto === "https" || proto === "http" || proto === "blob") return true;
|
||||
if (proto === "data") return /^data:image\//i.test(normalized);
|
||||
return false;
|
||||
|
||||
@@ -135,7 +135,7 @@ export function applyCaptionOverrides(): void {
|
||||
|
||||
// Use the first tween's color as the dim baseline — if no tweens,
|
||||
// fall back to computed style.
|
||||
const dimBaseline = colorTweens.length > 0 ? String(colorTweens[0].vars.color) : "";
|
||||
const dimBaseline = colorTweens[0] ? String(colorTweens[0].vars.color) : "";
|
||||
|
||||
for (const tw of colorTweens) {
|
||||
const tweenColor = String(tw.vars.color);
|
||||
|
||||
@@ -542,7 +542,7 @@ function createProgram(
|
||||
return program;
|
||||
}
|
||||
|
||||
function createTexture(gl: WebGLRenderingContext, filter = gl.LINEAR): WebGLTexture | null {
|
||||
function createTexture(gl: WebGLRenderingContext, filter: number = gl.LINEAR): WebGLTexture | null {
|
||||
const texture = gl.createTexture();
|
||||
if (!texture) return null;
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
|
||||
@@ -366,14 +366,15 @@ async function mountCompositionContent(params: {
|
||||
details: Record<string, string | number | boolean | null | string[]>;
|
||||
}) => void;
|
||||
}): Promise<void> {
|
||||
let innerRoot: Element | null = null;
|
||||
let innerRoot: HTMLElement | null = null;
|
||||
if (params.authoredCompositionId) {
|
||||
const candidateRoots = Array.from(
|
||||
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
|
||||
params.sourceNode.querySelectorAll<HTMLElement>("[data-composition-id]"),
|
||||
);
|
||||
innerRoot =
|
||||
candidateRoots.find(
|
||||
(candidate) =>
|
||||
candidate instanceof HTMLElement &&
|
||||
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
pause: () => {
|
||||
state.paused = true;
|
||||
},
|
||||
seek: (time: number) => {
|
||||
state.time = time;
|
||||
seek: (time?: number) => {
|
||||
if (time !== undefined) state.time = time;
|
||||
return state.time;
|
||||
},
|
||||
totalTime: (time: number) => {
|
||||
state.time = time;
|
||||
totalTime: (time?: number) => {
|
||||
if (time !== undefined) state.time = time;
|
||||
return state.time;
|
||||
},
|
||||
time: () => state.time,
|
||||
duration: () => state.duration,
|
||||
@@ -1756,6 +1758,26 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(seekTimes.length).toBeGreaterThan(beforeResume);
|
||||
});
|
||||
|
||||
it("keeps a usable bound timeline when the registry entry is replaced", () => {
|
||||
const raf = createManualRaf();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
||||
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div data-composition-id="root" data-start="0" data-duration="5" data-width="1920" data-height="1080"></div>
|
||||
`;
|
||||
const originalTimeline = createMockTimeline(5);
|
||||
window.__timelines = { root: originalTimeline };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const replacementTimeline = createMockTimeline(8);
|
||||
window.__timelines.root = replacementTimeline;
|
||||
for (let frame = 0; frame < 60; frame += 1) raf.step(16);
|
||||
|
||||
expect(window.__player?.getDuration()).toBe(5);
|
||||
});
|
||||
|
||||
// applyClipLayout force-absolutizes authored root-level timed clips so they
|
||||
// stack as overlays. But in Studio/preview the runtime also stamps `data-start`
|
||||
// onto ID'd / GSAP-targeted *flow* children (a <header>/<footer> in a column)
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
} from "./types";
|
||||
import type { PlayerAPI } from "../core.types";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
|
||||
|
||||
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
|
||||
const AUTHORED_END_ATTR = "data-hf-authored-end";
|
||||
@@ -186,7 +187,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
} else {
|
||||
for (let i = 0; i < arr.length; i++) normalized[`tl-${i}`] = arr[i];
|
||||
}
|
||||
(window as Record<string, unknown>).__timelines = normalized;
|
||||
(window as unknown as Record<string, unknown>).__timelines = normalized;
|
||||
}
|
||||
|
||||
// Agents sometimes omit data-start on the root composition element. The
|
||||
@@ -295,7 +296,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const MIN_VALID_TIMELINE_DURATION_SECONDS = 1 / 60;
|
||||
const TIMELINE_FLOOR_COVERAGE_RATIO = 0.75;
|
||||
const PLAY_REBIND_HOLD_SECONDS = 2;
|
||||
const METADATA_REBIND_MIN_DURATION_GAIN_SECONDS = 0.05;
|
||||
const METADATA_REBIND_DEBOUNCE_MS = 100;
|
||||
const MAX_DIAGNOSTIC_MESSAGE_LENGTH = 240;
|
||||
@@ -776,7 +776,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
!!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function",
|
||||
);
|
||||
if (usable.length !== 1) return { timeline: null };
|
||||
const [soleId, soleTimeline] = usable[0];
|
||||
const sole = usable[0];
|
||||
if (!sole) return { timeline: null };
|
||||
const [soleId, soleTimeline] = sole;
|
||||
return {
|
||||
timeline: soleTimeline,
|
||||
selectedTimelineIds: [soleId],
|
||||
@@ -1223,7 +1225,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
// reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook
|
||||
// directly here as well, since the wrapper may not be installed yet
|
||||
// during initial rebind (timing race on first load / soft reload).
|
||||
const applyFn = (window as Record<string, unknown>).__hfStudioManualEditsApply;
|
||||
const applyFn = (window as unknown as Record<string, unknown>).__hfStudioManualEditsApply;
|
||||
if (typeof applyFn === "function") applyFn();
|
||||
|
||||
// SDK moveElement edits (data-hf-edit-base-x/y markers) render as a
|
||||
@@ -1996,8 +1998,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
// handler. Identity is stable as long as the inputs are stable (each
|
||||
// adapter is expected to return the same promise on repeat calls while
|
||||
// its work is in flight).
|
||||
const firstPromise = promises[0];
|
||||
if (!firstPromise) return true;
|
||||
const combined: PromiseLike<unknown> =
|
||||
promises.length === 1 ? promises[0] : Promise.all(promises);
|
||||
promises.length === 1 ? firstPromise : Promise.all(promises);
|
||||
if (combined !== trackedAdapterReadyPromise) {
|
||||
trackedAdapterReadyPromise = combined;
|
||||
trackedAdapterReadySettled = false;
|
||||
@@ -2512,10 +2516,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (!isObjectRecord(child) || !isObjectRecord(child.vars)) continue;
|
||||
const hasCallback = GSAP_CALLBACK_NAMES.some(
|
||||
(name) => typeof child.vars[name] === "function",
|
||||
);
|
||||
if (!isObjectRecord(child)) continue;
|
||||
const vars = child.vars;
|
||||
if (!isObjectRecord(vars)) continue;
|
||||
const hasCallback = GSAP_CALLBACK_NAMES.some((name) => typeof vars[name] === "function");
|
||||
if (!hasCallback) continue;
|
||||
|
||||
const totalDuration = readGsapDuration(child, "totalDuration");
|
||||
@@ -2629,24 +2633,25 @@ export function initSandboxRuntimeModular(): void {
|
||||
transportTickCount += 1;
|
||||
|
||||
// Slower operations: timeline binding (~every 60 frames / ~1s at 60fps)
|
||||
if (transportTickCount % 60 === 0) {
|
||||
const shouldHoldRebind =
|
||||
clock.isPlaying() &&
|
||||
state.capturedTimeline != null &&
|
||||
clock.now() < PLAY_REBIND_HOLD_SECONDS;
|
||||
if (!shouldHoldRebind) {
|
||||
const prevTimeline = state.capturedTimeline;
|
||||
if (bindRootTimelineIfAvailable()) {
|
||||
if (state.capturedTimeline && !player._timeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
|
||||
state.capturedTimeline.pause();
|
||||
}
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (dur > 0) clock.setDuration(dur);
|
||||
postTimeline();
|
||||
if (
|
||||
shouldAttemptPeriodicTimelineBind({
|
||||
tick: transportTickCount,
|
||||
isPlaying: clock.isPlaying(),
|
||||
hasCapturedTimeline: state.capturedTimeline != null,
|
||||
currentTimeSeconds: clock.now(),
|
||||
})
|
||||
) {
|
||||
const prevTimeline = state.capturedTimeline;
|
||||
if (bindRootTimelineIfAvailable()) {
|
||||
if (state.capturedTimeline && !player._timeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
|
||||
state.capturedTimeline.pause();
|
||||
}
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (dur > 0) clock.setDuration(dur);
|
||||
postTimeline();
|
||||
}
|
||||
}
|
||||
if (transportTickCount % 20 === 0) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
|
||||
/**
|
||||
* Shared volume-automation utilities used by both the renderer (offline PCM
|
||||
* baking in audioVolumeEnvelope.ts) and the preview runtime (per-tick gain
|
||||
@@ -125,10 +127,7 @@ export function probeElementVolumeKeyframes(
|
||||
return hasAutomation ? keyframes : null;
|
||||
}
|
||||
|
||||
export interface RuntimeTimelineRef {
|
||||
totalTime?: ((t?: number, suppressEvents?: boolean) => unknown) | undefined;
|
||||
seek?: ((t?: number, suppressEvents?: boolean) => unknown) | undefined;
|
||||
}
|
||||
export type RuntimeTimelineRef = Partial<Pick<RuntimeTimelineLike, "totalTime" | "seek">>;
|
||||
|
||||
/**
|
||||
* Probe a media element and, if volume automation is detected, store the
|
||||
|
||||
@@ -142,8 +142,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
if (blocksPickerAtPoint(raw[0] ?? null)) return [];
|
||||
const dedupe: Record<string, true> = {};
|
||||
const candidates: Element[] = [];
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const node = raw[i];
|
||||
for (const [i, node] of raw.entries()) {
|
||||
if (!isPickableElement(node)) continue;
|
||||
const key = `${node.tagName}::${(node as HTMLElement).id || ""}::${i}`;
|
||||
if (dedupe[key]) continue;
|
||||
@@ -157,8 +156,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
function extractElementInfo(el: Element): RuntimePickerElementInfo {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const dataAttributes: Record<string, string> = {};
|
||||
for (let i = 0; i < el.attributes.length; i += 1) {
|
||||
const attr = el.attributes[i];
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name.startsWith("data-")) {
|
||||
dataAttributes[attr.name] = attr.value;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ function createMockTimeline(opts?: { time?: number; duration?: number }): Runtim
|
||||
pause: vi.fn(() => {
|
||||
state.paused = true;
|
||||
}),
|
||||
seek: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
seek: vi.fn((t?: number) => {
|
||||
if (t !== undefined) state.time = t;
|
||||
return state.time;
|
||||
}),
|
||||
totalTime: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
totalTime: vi.fn((t?: number) => {
|
||||
if (t !== undefined) state.time = t;
|
||||
return state.time;
|
||||
}),
|
||||
time: vi.fn(() => state.time),
|
||||
duration: vi.fn(() => state.duration),
|
||||
@@ -64,11 +66,13 @@ function createNestedTimelineHarness() {
|
||||
pause: vi.fn(() => {
|
||||
state.paused = true;
|
||||
}),
|
||||
seek: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
seek: vi.fn((t?: number) => {
|
||||
if (t !== undefined) state.time = t;
|
||||
return state.time;
|
||||
}),
|
||||
totalTime: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
totalTime: vi.fn((t?: number) => {
|
||||
if (t !== undefined) state.time = t;
|
||||
return state.time;
|
||||
}),
|
||||
time: vi.fn(() => state.time),
|
||||
duration: vi.fn(() => duration),
|
||||
@@ -95,14 +99,16 @@ function createNestedTimelineHarness() {
|
||||
pause: vi.fn(() => {
|
||||
masterState.paused = true;
|
||||
}),
|
||||
seek: vi.fn((t: number) => {
|
||||
seek: vi.fn((t?: number) => {
|
||||
if (t === undefined) return masterState.time;
|
||||
masterState.time = t;
|
||||
for (const child of children) {
|
||||
if (child.state.paused) continue;
|
||||
child.state.time = Math.max(0, Math.min(t - child.start, child.duration));
|
||||
}
|
||||
}),
|
||||
totalTime: vi.fn((t: number) => {
|
||||
totalTime: vi.fn((t?: number) => {
|
||||
if (t === undefined) return masterState.time;
|
||||
masterState.time = t;
|
||||
for (const child of children) {
|
||||
if (child.state.paused) continue;
|
||||
|
||||
@@ -385,8 +385,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
),
|
||||
);
|
||||
let maxEnd = 0;
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const node = nodes[i];
|
||||
for (const [i, node] of nodes.entries()) {
|
||||
if (node === root) continue;
|
||||
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName))
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PLAY_REBIND_HOLD_SECONDS,
|
||||
TIMELINE_REBIND_INTERVAL_FRAMES,
|
||||
shouldAttemptPeriodicTimelineBind,
|
||||
} from "./timelineRebindPolicy";
|
||||
|
||||
describe("shouldAttemptPeriodicTimelineBind", () => {
|
||||
it("only checks for replacements at the periodic boundary", () => {
|
||||
expect(
|
||||
shouldAttemptPeriodicTimelineBind({
|
||||
tick: TIMELINE_REBIND_INTERVAL_FRAMES - 1,
|
||||
isPlaying: false,
|
||||
hasCapturedTimeline: true,
|
||||
currentTimeSeconds: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAttemptPeriodicTimelineBind({
|
||||
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
|
||||
isPlaying: false,
|
||||
hasCapturedTimeline: true,
|
||||
currentTimeSeconds: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("holds a captured timeline during the first playback seconds", () => {
|
||||
expect(
|
||||
shouldAttemptPeriodicTimelineBind({
|
||||
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
|
||||
isPlaying: true,
|
||||
hasCapturedTimeline: true,
|
||||
currentTimeSeconds: PLAY_REBIND_HOLD_SECONDS - 0.001,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAttemptPeriodicTimelineBind({
|
||||
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
|
||||
isPlaying: true,
|
||||
hasCapturedTimeline: true,
|
||||
currentTimeSeconds: PLAY_REBIND_HOLD_SECONDS,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not hold when no timeline has bound yet", () => {
|
||||
expect(
|
||||
shouldAttemptPeriodicTimelineBind({
|
||||
tick: TIMELINE_REBIND_INTERVAL_FRAMES,
|
||||
isPlaying: true,
|
||||
hasCapturedTimeline: false,
|
||||
currentTimeSeconds: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export const TIMELINE_REBIND_INTERVAL_FRAMES = 60;
|
||||
export const PLAY_REBIND_HOLD_SECONDS = 2;
|
||||
|
||||
export function shouldAttemptPeriodicTimelineBind(input: {
|
||||
tick: number;
|
||||
isPlaying: boolean;
|
||||
hasCapturedTimeline: boolean;
|
||||
currentTimeSeconds: number;
|
||||
}): boolean {
|
||||
if (
|
||||
!Number.isInteger(input.tick) ||
|
||||
input.tick <= 0 ||
|
||||
input.tick % TIMELINE_REBIND_INTERVAL_FRAMES !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
input.isPlaying &&
|
||||
input.hasCapturedTimeline &&
|
||||
input.currentTimeSeconds < PLAY_REBIND_HOLD_SECONDS
|
||||
);
|
||||
}
|
||||
@@ -228,17 +228,32 @@ export type RuntimeSeekOptions = {
|
||||
suppressEvents?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeTimelineChildLike = {
|
||||
targets?: () => unknown[];
|
||||
vars?: unknown;
|
||||
startTime?: () => number;
|
||||
duration?: () => number;
|
||||
parent?: RuntimeTimelineChildLike;
|
||||
};
|
||||
|
||||
export type RuntimeTimelineLike = {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (timeSeconds: number, suppressEvents?: boolean) => void;
|
||||
totalTime?: (timeSeconds: number, suppressEvents?: boolean) => void;
|
||||
seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
|
||||
totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
|
||||
progress?: (value?: number, suppressEvents?: boolean) => unknown;
|
||||
time: () => number;
|
||||
duration: () => number;
|
||||
add: (timeline: RuntimeTimelineLike, startAtSeconds: number) => void;
|
||||
paused: (paused?: boolean) => void;
|
||||
timeScale?: (rate: number) => void;
|
||||
set: (target: RuntimeGsapSetTarget, vars: RuntimeGsapSetVars, atSeconds?: number) => void;
|
||||
getChildren?: (
|
||||
nested?: boolean,
|
||||
tweens?: boolean,
|
||||
timelines?: boolean,
|
||||
ignoreBeforeTime?: number,
|
||||
) => RuntimeTimelineChildLike[];
|
||||
};
|
||||
|
||||
export type RuntimeDeterministicAdapter = {
|
||||
|
||||
Reference in New Issue
Block a user