mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
Merge pull request #2147 from heygen-com/07-10-fix_core_enforce_strict_runtime_safety
fix(core): enforce strict runtime safety
This commit is contained in:
@@ -372,6 +372,9 @@ jobs:
|
||||
node-version: 22
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- run: bun install --frozen-lockfile
|
||||
# Runtime coverage now imports core modules that consume workspace
|
||||
# subpaths. Build their dist exports before Vitest resolves them.
|
||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||
- run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci
|
||||
|
||||
studio-load-smoke:
|
||||
|
||||
@@ -402,8 +402,10 @@
|
||||
"test": "bun run check:position-edits-render && vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint:runtime-preview-guards": "tsx scripts/lint-runtime-preview-guards.ts",
|
||||
"test:runtime-coverage": "vitest run --coverage src/runtime",
|
||||
"typecheck": "tsc --noEmit && bun run typecheck:runtime",
|
||||
"typecheck:runtime": "tsc --noEmit -p tsconfig.runtime.json",
|
||||
"lint:runtime-preview-guards": "bun scripts/lint-runtime-preview-guards.ts",
|
||||
"build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
|
||||
"build:position-edits-render": "tsx scripts/build-position-edits-render.ts",
|
||||
"check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts",
|
||||
@@ -415,9 +417,9 @@
|
||||
"test:hyperframe-runtime-duration-guards": "tsx scripts/test-hyperframe-runtime-duration-guards.ts",
|
||||
"test:hyperframe-runtime-parity": "tsx scripts/test-hyperframe-runtime-parity.ts",
|
||||
"test:hyperframe-runtime-security": "tsx scripts/test-hyperframe-runtime-security.ts",
|
||||
"test:hyperframe-linter": "tsx scripts/test-hyperframe-linter.ts",
|
||||
"test:hyperframe-runtime-ci": "bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security",
|
||||
"check:hyperframe-html": "tsx scripts/check-hyperframe-static.ts",
|
||||
"test:hyperframe-linter": "bun scripts/test-hyperframe-linter.ts",
|
||||
"test:hyperframe-runtime-ci": "bun run typecheck:runtime && bun run lint:runtime-preview-guards && bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security && bun run test:runtime-coverage && bun run test:hyperframe-linter",
|
||||
"check:hyperframe-html": "bun scripts/check-hyperframe-static.ts",
|
||||
"debug:timeline": "tsx scripts/debug-timeline.ts",
|
||||
"prepublishOnly": "echo skip"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { lintHyperframeHtml } from "../src/lint/hyperframeLinter";
|
||||
import type { HyperframeLintResult } from "../src/lint/types";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";
|
||||
|
||||
function formatCounts(result: HyperframeLintResult): string {
|
||||
const parts = [`${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`];
|
||||
|
||||
@@ -13,6 +13,9 @@ type GuardCheckResult = {
|
||||
failed: GuardSpec[];
|
||||
};
|
||||
|
||||
// Keep only temporary source-shape guards that do not yet have behavioral
|
||||
// coverage. Timeline replacement and early-play rebinding are exercised by
|
||||
// init.test.ts and timelineRebindPolicy.test.ts instead of regexes.
|
||||
const GUARD_SPECS: GuardSpec[] = [
|
||||
{
|
||||
id: "external_compositions_gate",
|
||||
@@ -20,12 +23,6 @@ const GUARD_SPECS: GuardSpec[] = [
|
||||
filePath: "src/runtime/init.ts",
|
||||
pattern: /if\s*\(\s*!externalCompositionsReady\s*\)\s*return\s+false;/,
|
||||
},
|
||||
{
|
||||
id: "usable_timeline_gate",
|
||||
description: "Skip rebinding when current timeline is already usable",
|
||||
filePath: "src/runtime/init.ts",
|
||||
pattern: /if\s*\(\s*currentTimeline\s*&&\s*currentTimelineUsable\s*\)\s*return\s+false;/,
|
||||
},
|
||||
{
|
||||
id: "child_timeline_activation",
|
||||
description: "Force root child timelines active before composition binding",
|
||||
@@ -39,18 +36,6 @@ const GUARD_SPECS: GuardSpec[] = [
|
||||
pattern:
|
||||
/if\s*\(\s*!isUsableTimelineDuration\(rootDurationSeconds\)\s*&&\s*rootChildCandidates\.length\s*>\s*0\s*\)/,
|
||||
},
|
||||
{
|
||||
id: "loop_guard_rebind",
|
||||
description: "Enable loop guard based timeline rebinding",
|
||||
filePath: "src/runtime/init.ts",
|
||||
pattern: /if\s*\(\s*rebindTimelineFromResolution\(resolution,\s*"loop_guard"\)\s*\)/,
|
||||
},
|
||||
{
|
||||
id: "early_play_rebind_hold",
|
||||
description: "Hold rebinding during first playback seconds",
|
||||
filePath: "src/runtime/init.ts",
|
||||
pattern: /shouldHoldRebindDuringEarlyPlay/,
|
||||
},
|
||||
{
|
||||
id: "external_script_ordering",
|
||||
description: "Inject external composition scripts with deterministic ordering",
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { lintHyperframeHtml } from "../src/lint/hyperframeLinter";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { lintHyperframeHtml } from "@hyperframes/lint";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const VALID_COMPOSITION = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script src="https://cdn.gsap.com/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["comp-1"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function testCleanFixturePasses() {
|
||||
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
|
||||
const html = fs.readFileSync(fixturePath, "utf8");
|
||||
const result = lintHyperframeHtml(html, { filePath: fixturePath });
|
||||
async function testCleanFixturePasses() {
|
||||
const result = await lintHyperframeHtml(VALID_COMPOSITION, { filePath: "valid.html" });
|
||||
|
||||
assert.equal(result.ok, true, "chat-project-9 should pass without lint errors");
|
||||
assert.equal(result.errorCount, 0, "chat-project-9 should have zero lint errors");
|
||||
assert.equal(result.ok, true, "valid composition should pass without lint errors");
|
||||
assert.equal(result.errorCount, 0, "valid composition should have zero lint errors");
|
||||
}
|
||||
|
||||
function testDetectsMissingCompositionHostId() {
|
||||
async function testDetectsMissingCompositionHostId() {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
@@ -31,7 +46,7 @@ function testDetectsMissingCompositionHostId() {
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const codes = result.findings.map((finding) => finding.code);
|
||||
|
||||
assert.equal(result.ok, false, "missing composition ids should fail lint");
|
||||
@@ -39,7 +54,7 @@ function testDetectsMissingCompositionHostId() {
|
||||
assert.ok(codes.includes("host_missing_composition_id"));
|
||||
}
|
||||
|
||||
function testDetectsOverlappingGsapTweens() {
|
||||
async function testDetectsOverlappingGsapTweens() {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
@@ -57,7 +72,7 @@ function testDetectsOverlappingGsapTweens() {
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = lintHyperframeHtml(html);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const overlapFinding = result.findings.find(
|
||||
(finding) => finding.code === "overlapping_gsap_tweens",
|
||||
);
|
||||
@@ -67,29 +82,30 @@ function testDetectsOverlappingGsapTweens() {
|
||||
}
|
||||
|
||||
function testCliJsonOutput() {
|
||||
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
|
||||
const tsxBin = path.join(ROOT, "node_modules/.bin/tsx");
|
||||
const stdout = execFileSync(
|
||||
tsxBin,
|
||||
["scripts/check-hyperframe-static.ts", "--json", fixturePath],
|
||||
{
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "hf-core-lint-script-"));
|
||||
try {
|
||||
const fixturePath = path.join(tempDir, "index.html");
|
||||
writeFileSync(fixturePath, VALID_COMPOSITION, "utf8");
|
||||
const stdout = execFileSync("bun", ["run", "check:hyperframe-html", "--json", fixturePath], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout);
|
||||
});
|
||||
const payload = JSON.parse(stdout);
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(typeof payload.errorCount, "number");
|
||||
assert.ok(Array.isArray(payload.findings));
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(typeof payload.errorCount, "number");
|
||||
assert.ok(Array.isArray(payload.findings));
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
testCleanFixturePasses();
|
||||
testDetectsMissingCompositionHostId();
|
||||
testDetectsOverlappingGsapTweens();
|
||||
async function main() {
|
||||
await testCleanFixturePasses();
|
||||
await testDetectsMissingCompositionHostId();
|
||||
await testDetectsOverlappingGsapTweens();
|
||||
testCliJsonOutput();
|
||||
console.log("hyperframe linter tests passed");
|
||||
}
|
||||
|
||||
main();
|
||||
await main();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"files": [],
|
||||
"include": ["src/runtime/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export default defineConfig({
|
||||
"src/runtime/types.ts",
|
||||
"src/runtime/window.d.ts",
|
||||
"src/runtime/entry.ts",
|
||||
"src/runtime/init.ts",
|
||||
"src/runtime/README.md",
|
||||
],
|
||||
thresholds: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PRODUCER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".
|
||||
// or local sockets. Keep the list explicit so a filename-only rename does not
|
||||
// make Git/fallow re-audit thousands of unchanged test lines as new code.
|
||||
const INTEGRATION_TEST_FILES = new Set([
|
||||
"src/services/coreRuntimeBrowser.test.ts",
|
||||
"src/services/deterministicFonts-systemCapture.test.ts",
|
||||
"src/services/distributed/assemble.test.ts",
|
||||
"src/services/distributed/chunkBoundary.test.ts",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import puppeteer, { type Browser, type Page } from "puppeteer";
|
||||
|
||||
const RUNTIME_PATH = resolve(import.meta.dirname, "../../../core/dist/hyperframe.runtime.iife.js");
|
||||
|
||||
describe("core runtime browser contract", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
page = await browser.newPage();
|
||||
await page.setContent(`<!doctype html>
|
||||
<style>
|
||||
@keyframes slide { from { transform: translateX(0); } to { transform: translateX(100px); } }
|
||||
#box { animation: slide 2s linear both; }
|
||||
</style>
|
||||
<div data-composition-id="root" data-start="0" data-duration="2" data-width="320" data-height="180">
|
||||
<div id="box"></div>
|
||||
</div>`);
|
||||
await page.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as unknown as { __playerReady?: boolean }).__playerReady === true &&
|
||||
(window as unknown as { __renderReady?: boolean }).__renderReady === true,
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it("initializes the public player contract and seeks the CSS adapter", async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
const runtimeWindow = window as unknown as {
|
||||
__player?: {
|
||||
play?: () => void;
|
||||
pause?: () => void;
|
||||
renderSeek?: (timeSeconds: number) => void;
|
||||
getDuration?: () => number;
|
||||
isPlaying?: () => boolean;
|
||||
};
|
||||
};
|
||||
const player = runtimeWindow.__player;
|
||||
player?.renderSeek?.(1);
|
||||
const animation = document.getElementById("box")?.getAnimations()[0];
|
||||
return {
|
||||
hasPlay: typeof player?.play === "function",
|
||||
hasPause: typeof player?.pause === "function",
|
||||
hasRenderSeek: typeof player?.renderSeek === "function",
|
||||
duration: player?.getDuration?.(),
|
||||
animationTime: Number(animation?.currentTime),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasPlay: true,
|
||||
hasPause: true,
|
||||
hasRenderSeek: true,
|
||||
duration: 2,
|
||||
animationTime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the control bridge during teardown", async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtimeWindow = window as unknown as {
|
||||
__hfRuntimeTeardown?: (() => void) | null;
|
||||
__player?: { isPlaying?: () => boolean };
|
||||
};
|
||||
const hadTeardown = typeof runtimeWindow.__hfRuntimeTeardown === "function";
|
||||
runtimeWindow.__hfRuntimeTeardown?.();
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "hf-parent", type: "control", action: "play" },
|
||||
}),
|
||||
);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => resolveFrame(undefined)));
|
||||
return {
|
||||
hadTeardown,
|
||||
teardownCleared: runtimeWindow.__hfRuntimeTeardown === null,
|
||||
isPlaying: runtimeWindow.__player?.isPlaying?.(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result).toEqual({ hadTeardown: true, teardownCleared: true, isPlaying: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user