Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 7 additions & 5 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
3 changes: 1 addition & 2 deletions packages/core/scripts/check-hyperframe-static.ts
Original file line number Diff line number Diff line change
@@ -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"}`];
Expand Down
21 changes: 3 additions & 18 deletions packages/core/scripts/lint-runtime-preview-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,16 @@ 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",
description: "Do not bind timelines before external compositions are loaded",
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",
Expand All @@ -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",
Expand Down
76 changes: 46 additions & 30 deletions packages/core/scripts/test-hyperframe-linter.ts
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -31,15 +46,15 @@ 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");
assert.ok(codes.includes("root_missing_composition_id"));
assert.ok(codes.includes("host_missing_composition_id"));
}

function testDetectsOverlappingGsapTweens() {
async function testDetectsOverlappingGsapTweens() {
const html = `
<html>
<body>
Expand All @@ -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",
);
Expand All @@ -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();
2 changes: 1 addition & 1 deletion packages/core/src/runtime/adapters/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/runtime/adapters/waapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/runtime/applyVariableBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/captionOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/colorGrading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/runtime/compositionLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
30 changes: 26 additions & 4 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading