fix(core): enforce strict runtime safety

This commit is contained in:
James
2026-07-11 21:31:30 -07:00
parent ebbd1eb2c2
commit 0af07a07c5
24 changed files with 348 additions and 116 deletions
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
}
+26 -4
View File
@@ -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)
+31 -26
View File
@@ -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
+2 -4
View File
@@ -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;
}
+16 -10
View File
@@ -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;
+1 -2
View File
@@ -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
);
}
+17 -2
View File
@@ -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 = {