chore: import upstream snapshot with attribution
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildSync } from "esbuild";
|
||||
import {
|
||||
HYPERFRAME_RUNTIME_ARTIFACTS,
|
||||
HYPERFRAME_RUNTIME_CONTRACT,
|
||||
loadHyperframeRuntimeSource,
|
||||
} from "../src/inline-scripts/hyperframe";
|
||||
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = resolve(thisDir, "../dist");
|
||||
const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
|
||||
const esmPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.esm);
|
||||
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
|
||||
|
||||
const runtimeSourceRaw = loadHyperframeRuntimeSource();
|
||||
if (runtimeSourceRaw === null) {
|
||||
throw new Error("Cannot build runtime artifact: entry.ts not found at expected path");
|
||||
}
|
||||
const runtimeSource = `${runtimeSourceRaw}\n`;
|
||||
const runtimeSha256 = createHash("sha256").update(runtimeSource, "utf8").digest("hex");
|
||||
const buildId = process.env.HYPERFRAME_RUNTIME_BUILD_ID?.trim() || "dev";
|
||||
const runtimeEntryPath = resolve(thisDir, "../src/runtime/entry.ts");
|
||||
const esmBuild = buildSync({
|
||||
entryPoints: [runtimeEntryPath],
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
target: ["es2020"],
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
});
|
||||
const esmSource = `${esmBuild.outputFiles[0]?.text ?? ""}\n`;
|
||||
|
||||
const manifest = {
|
||||
version: process.env.HYPERFRAME_RUNTIME_VERSION?.trim() || "0.1.0",
|
||||
buildId,
|
||||
sha256: runtimeSha256,
|
||||
artifacts: {
|
||||
iife: HYPERFRAME_RUNTIME_ARTIFACTS.iife,
|
||||
esm: HYPERFRAME_RUNTIME_ARTIFACTS.esm,
|
||||
},
|
||||
contract: HYPERFRAME_RUNTIME_CONTRACT,
|
||||
};
|
||||
|
||||
mkdirSync(distDir, { recursive: true });
|
||||
writeFileSync(iifePath, runtimeSource, "utf8");
|
||||
writeFileSync(esmPath, esmSource, "utf8");
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
|
||||
// ── Generate src/generated/runtime-inline.ts ──────────────────────────────
|
||||
// This file is compiled by tsc into dist/ and provides the production-safe
|
||||
// getHyperframeRuntimeScript() that returns the IIFE as a string constant —
|
||||
// no esbuild, no file I/O, no import.meta.url arithmetic.
|
||||
const generatedDir = resolve(thisDir, "../src/generated");
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
const inlineModulePath = resolve(generatedDir, "runtime-inline.ts");
|
||||
const escapedSource = JSON.stringify(runtimeSourceRaw);
|
||||
writeFileSync(
|
||||
inlineModulePath,
|
||||
[
|
||||
"// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit",
|
||||
`const RUNTIME_IIFE: string = ${escapedSource};`,
|
||||
"",
|
||||
"/**",
|
||||
" * Returns the pre-built hyperframe runtime IIFE as a string constant.",
|
||||
" * This is the production-safe path: no esbuild, no file I/O,",
|
||||
" * no import.meta.url arithmetic.",
|
||||
" */",
|
||||
"export function getHyperframeRuntimeScript(): string {",
|
||||
" return RUNTIME_IIFE;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_artifacts_generated",
|
||||
buildId,
|
||||
distDir,
|
||||
iifePath,
|
||||
esmPath,
|
||||
manifestPath,
|
||||
inlineModulePath,
|
||||
sourceBytes: Buffer.byteLength(runtimeSource, "utf8"),
|
||||
esmBytes: Buffer.byteLength(esmSource, "utf8"),
|
||||
sha256: runtimeSha256,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Build the injectable position-edits render artifact from the canonical runtime. */
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildSync } from "esbuild";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(thisDir, "..");
|
||||
const entry = resolve(repoRoot, "stubs/position-edits-render-entry.ts");
|
||||
const generatedDir = resolve(repoRoot, "src/generated");
|
||||
const outPath = resolve(generatedDir, "position-edits-render-inline.ts");
|
||||
|
||||
const result = buildSync({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: "browser",
|
||||
format: "iife",
|
||||
target: ["es2020"],
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
});
|
||||
const iife = result.outputFiles[0]?.text ?? "";
|
||||
if (!iife) throw new Error("esbuild produced no output for position-edits-render-entry.ts");
|
||||
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
writeFileSync(
|
||||
outPath,
|
||||
[
|
||||
"// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit",
|
||||
`const POSITION_EDITS_RENDER_IIFE: string = ${JSON.stringify(iife)};`,
|
||||
"",
|
||||
"/** Returns the pre-built position-edits render IIFE as a string constant. */",
|
||||
"export function getPositionEditsRenderScript(): string {",
|
||||
" return POSITION_EDITS_RENDER_IIFE;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
execFileSync("bun", ["x", "oxfmt", outPath], { stdio: "ignore" });
|
||||
} catch {
|
||||
// Formatting is best effort when the generator runs in a minimal environment.
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({ event: "position_edits_render_generated", outPath, bytes: iife.length }),
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";
|
||||
|
||||
function formatCounts(result: HyperframeLintResult): string {
|
||||
const parts = [`${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`];
|
||||
if (result.infoCount > 0) {
|
||||
parts.push(`${result.infoCount} info${result.infoCount === 1 ? "" : "s"}`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function formatHumanOutput(result: HyperframeLintResult, resolvedPath: string): string {
|
||||
const counts = result.ok
|
||||
? formatCounts(result)
|
||||
: `${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${formatCounts(result)}`;
|
||||
const lines = [
|
||||
result.ok ? `PASS ${resolvedPath} (${counts})` : `FAIL ${resolvedPath} (${counts})`,
|
||||
];
|
||||
|
||||
for (const finding of result.findings) {
|
||||
lines.push(`- [${finding.severity.toUpperCase()}] ${finding.code}: ${finding.message}`);
|
||||
if (finding.selector) {
|
||||
lines.push(` selector: ${finding.selector}`);
|
||||
}
|
||||
if (finding.elementId) {
|
||||
lines.push(` elementId: ${finding.elementId}`);
|
||||
}
|
||||
if (finding.fixHint) {
|
||||
lines.push(` fix: ${finding.fixHint}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
|
||||
const jsonOutput = normalizedArgs.includes("--json");
|
||||
const positionalArgs = normalizedArgs.filter((arg) => arg !== "--json");
|
||||
const inputPath = positionalArgs[0];
|
||||
if (!inputPath) {
|
||||
console.error(
|
||||
"Usage: bun run check:hyperframe-html [--json] <path-to-html>\nExample: bun run check:hyperframe-html core/src/tests/broken-video.html",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(process.cwd(), inputPath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`File not found: ${resolvedPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(resolvedPath, "utf-8");
|
||||
const result = await lintHyperframeHtml(html, { filePath: resolvedPath });
|
||||
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
console.log(formatHumanOutput(result, resolvedPath));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(formatHumanOutput(result, resolvedPath));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,318 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type AttrMap = Record<string, string>;
|
||||
|
||||
type ParsedTag = {
|
||||
tagName: string;
|
||||
attrs: AttrMap;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
type TimelineClip = {
|
||||
id: string | null;
|
||||
label: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
kind: "video" | "audio" | "image" | "element";
|
||||
tagName: string | null;
|
||||
compositionId: string | null;
|
||||
compositionSrc: string | null;
|
||||
assetUrl: string | null;
|
||||
durationSource: "deterministic" | "fallback";
|
||||
};
|
||||
|
||||
type TimelinePayload = {
|
||||
source: "hf-preview";
|
||||
type: "timeline";
|
||||
durationInFrames: number;
|
||||
clips: TimelineClip[];
|
||||
scenes: [];
|
||||
compositionWidth: number;
|
||||
compositionHeight: number;
|
||||
};
|
||||
|
||||
function parseNum(value: string | null | undefined): number | null {
|
||||
if (value == null) return null;
|
||||
const parsed = Number.parseFloat(String(value));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
|
||||
const inputPath = normalizedArgs[0];
|
||||
let forcedFps: number | null = null;
|
||||
let forcedMaxDuration: number | null = null;
|
||||
for (let i = 1; i < normalizedArgs.length; i += 1) {
|
||||
const arg = normalizedArgs[i];
|
||||
if (arg === "--fps" && normalizedArgs[i + 1]) {
|
||||
forcedFps = parseNum(normalizedArgs[i + 1]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max-duration" && normalizedArgs[i + 1]) {
|
||||
forcedMaxDuration = parseNum(normalizedArgs[i + 1]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { inputPath, forcedFps, forcedMaxDuration };
|
||||
}
|
||||
|
||||
function parseAttributes(rawAttrs: string): AttrMap {
|
||||
const attrs: AttrMap = {};
|
||||
const attrRegex = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
while (true) {
|
||||
match = attrRegex.exec(rawAttrs);
|
||||
if (!match) break;
|
||||
const key = match[1];
|
||||
const value = match[3] ?? match[4] ?? match[5] ?? "";
|
||||
attrs[key] = value;
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function parseTags(html: string): ParsedTag[] {
|
||||
const tags: ParsedTag[] = [];
|
||||
const tagRegex = /<([a-zA-Z][\w:-]*)([^>]*)>/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
while (true) {
|
||||
match = tagRegex.exec(html);
|
||||
if (!match) break;
|
||||
const raw = match[0];
|
||||
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
|
||||
const tagName = String(match[1] || "").toLowerCase();
|
||||
if (!tagName) continue;
|
||||
tags.push({
|
||||
tagName,
|
||||
raw,
|
||||
attrs: parseAttributes(match[2] || ""),
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function extractWindowNumber(html: string, key: string): number | null {
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`${escapedKey}\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)`, "i");
|
||||
const match = html.match(regex);
|
||||
if (!match) return null;
|
||||
return parseNum(match[1]);
|
||||
}
|
||||
|
||||
function normalizeDurationSeconds(
|
||||
rawDuration: number | null,
|
||||
fallbackDuration: number | null,
|
||||
maxDuration: number,
|
||||
): number {
|
||||
const safeFallback = fallbackDuration != null && fallbackDuration > 0 ? fallbackDuration : 0;
|
||||
const safeRaw =
|
||||
rawDuration != null && Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 0;
|
||||
if (safeRaw > 0) return Math.min(safeRaw, maxDuration);
|
||||
if (safeFallback > 0) return Math.min(safeFallback, maxDuration);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function shouldIncludeTimelineNode(tag: ParsedTag, rootCompositionId: string | null): boolean {
|
||||
const attrs = tag.attrs;
|
||||
if (
|
||||
attrs["data-composition-id"] &&
|
||||
rootCompositionId &&
|
||||
attrs["data-composition-id"] === rootCompositionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
tag.tagName === "script" ||
|
||||
tag.tagName === "style" ||
|
||||
tag.tagName === "link" ||
|
||||
tag.tagName === "meta"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((attrs.class || "").split(/\s+/).includes("__preview_render_frame__")) return false;
|
||||
if (attrs["data-start"] != null) return true;
|
||||
if (attrs["data-track-index"] != null) return true;
|
||||
if (attrs["data-composition-src"] != null) return true;
|
||||
if (attrs["data-composition-id"] != null) return true;
|
||||
return tag.tagName === "video" || tag.tagName === "audio" || tag.tagName === "img";
|
||||
}
|
||||
|
||||
function inferClipDuration(tag: ParsedTag, start: number, maxDuration: number): number | null {
|
||||
const attrs = tag.attrs;
|
||||
const durationAttr = parseNum(attrs["data-duration"]);
|
||||
if (durationAttr != null && durationAttr > 0)
|
||||
return normalizeDurationSeconds(durationAttr, null, maxDuration);
|
||||
|
||||
const endAttr = parseNum(attrs["data-end"]);
|
||||
if (endAttr != null && endAttr > start) {
|
||||
return normalizeDurationSeconds(endAttr - start, null, maxDuration);
|
||||
}
|
||||
|
||||
if (tag.tagName === "video" || tag.tagName === "audio") {
|
||||
const sourceDuration = parseNum(attrs["data-source-duration"]);
|
||||
const playbackStart =
|
||||
parseNum(attrs["data-playback-start"]) ?? parseNum(attrs["data-playbackStart"]) ?? 0;
|
||||
if (sourceDuration != null && sourceDuration > 0) {
|
||||
return normalizeDurationSeconds(
|
||||
Math.max(0, sourceDuration - playbackStart),
|
||||
null,
|
||||
maxDuration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs["data-composition-id"]) {
|
||||
const sourceDuration = parseNum(attrs["data-source-duration"]);
|
||||
if (sourceDuration != null && sourceDuration > 0) {
|
||||
return normalizeDurationSeconds(sourceDuration, null, maxDuration);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveNodeAssetUrl(tag: ParsedTag): string | null {
|
||||
const attrs = tag.attrs;
|
||||
return (
|
||||
attrs.src ??
|
||||
attrs["data-src"] ??
|
||||
attrs["data-asset-src"] ??
|
||||
attrs["data-video-src"] ??
|
||||
attrs["data-image-src"] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRootTag(tags: ParsedTag[]): ParsedTag | null {
|
||||
const explicitRoot = tags.find((tag) => tag.attrs["data-composition-id"] === "master");
|
||||
if (explicitRoot) return explicitRoot;
|
||||
return tags.find((tag) => tag.attrs["data-composition-id"] != null) ?? null;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { inputPath, forcedFps, forcedMaxDuration } = parseArgs();
|
||||
if (!inputPath) {
|
||||
console.error("Usage: bun run debug:timeline <path-to-html> [--fps 30] [--max-duration 1800]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(process.cwd(), inputPath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
console.error(`File not found: ${resolvedPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(resolvedPath, "utf-8");
|
||||
const tags = parseTags(html);
|
||||
const root = resolveRootTag(tags);
|
||||
const rootCompositionId = root?.attrs["data-composition-id"] ?? null;
|
||||
|
||||
const maxDuration =
|
||||
(forcedMaxDuration != null && forcedMaxDuration > 0
|
||||
? forcedMaxDuration
|
||||
: extractWindowNumber(html, "window.__HF_MAX_DURATION_SEC")) ?? 1800;
|
||||
const canonicalFps =
|
||||
(forcedFps != null && forcedFps > 0
|
||||
? forcedFps
|
||||
: (parseNum(root?.attrs["data-fps"]) ?? extractWindowNumber(html, "window.__HF_FPS"))) ?? 30;
|
||||
|
||||
const rootDurationRaw =
|
||||
parseNum(root?.attrs["data-composition-duration"]) ??
|
||||
parseNum(root?.attrs["data-duration"]) ??
|
||||
parseNum(
|
||||
tags.find((tag) => tag.tagName === "html")?.attrs["data-composition-duration"] ?? null,
|
||||
);
|
||||
const rootDuration = normalizeDurationSeconds(rootDurationRaw, null, maxDuration);
|
||||
|
||||
const nodes = tags.filter((tag) => shouldIncludeTimelineNode(tag, rootCompositionId));
|
||||
const clips: TimelineClip[] = [];
|
||||
let maxEnd = 0;
|
||||
let unresolvedClipCount = 0;
|
||||
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const node = nodes[i];
|
||||
const attrs = node.attrs;
|
||||
const start = Math.max(0, parseNum(attrs["data-start"]) ?? 0);
|
||||
const inferredDuration = inferClipDuration(node, start, maxDuration);
|
||||
const hasDeterministicDuration = inferredDuration != null && inferredDuration > 0;
|
||||
let duration = hasDeterministicDuration
|
||||
? normalizeDurationSeconds(inferredDuration, 0, maxDuration)
|
||||
: 0;
|
||||
let durationSource: "deterministic" | "fallback" = "deterministic";
|
||||
if (duration <= 0 && rootDuration > start) {
|
||||
duration = normalizeDurationSeconds(rootDuration - start, 0, maxDuration);
|
||||
durationSource = "fallback";
|
||||
}
|
||||
if (duration <= 0) {
|
||||
unresolvedClipCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (hasDeterministicDuration) {
|
||||
const end = start + duration;
|
||||
if (end > maxEnd) maxEnd = end;
|
||||
}
|
||||
const kind =
|
||||
node.tagName === "video"
|
||||
? "video"
|
||||
: node.tagName === "audio"
|
||||
? "audio"
|
||||
: node.tagName === "img"
|
||||
? "image"
|
||||
: "element";
|
||||
const trackRaw = parseNum(attrs["data-track-index"]);
|
||||
const track = trackRaw != null && Number.isFinite(trackRaw) ? Math.floor(trackRaw) : i;
|
||||
clips.push({
|
||||
id: attrs.id ?? null,
|
||||
label: attrs["data-label"] ?? attrs["aria-label"] ?? attrs.id ?? kind,
|
||||
start,
|
||||
duration,
|
||||
track,
|
||||
kind,
|
||||
tagName: node.tagName || null,
|
||||
compositionId: attrs["data-composition-id"] ?? null,
|
||||
compositionSrc: attrs["data-composition-src"] ?? null,
|
||||
assetUrl: resolveNodeAssetUrl(node),
|
||||
durationSource,
|
||||
});
|
||||
}
|
||||
|
||||
let effectiveDuration = 0;
|
||||
if (maxEnd > 0) effectiveDuration = normalizeDurationSeconds(maxEnd, 0, maxDuration);
|
||||
if (effectiveDuration <= 0)
|
||||
effectiveDuration = normalizeDurationSeconds(rootDuration, 1, maxDuration);
|
||||
if (effectiveDuration <= 0) effectiveDuration = 1;
|
||||
|
||||
const compositionWidth = parseNum(root?.attrs["data-width"]) ?? 1920;
|
||||
const compositionHeight = parseNum(root?.attrs["data-height"]) ?? 1080;
|
||||
const payload: TimelinePayload = {
|
||||
source: "hf-preview",
|
||||
type: "timeline",
|
||||
durationInFrames: Math.max(1, Math.round(effectiveDuration * canonicalFps)),
|
||||
clips,
|
||||
scenes: [],
|
||||
compositionWidth,
|
||||
compositionHeight,
|
||||
};
|
||||
|
||||
const output = {
|
||||
file: resolvedPath,
|
||||
debug: {
|
||||
canonicalFps,
|
||||
maxDurationSeconds: maxDuration,
|
||||
rootCompositionId,
|
||||
rootDurationSeconds: rootDuration,
|
||||
deterministicMaxEndSeconds: maxEnd,
|
||||
effectiveDurationSeconds: effectiveDuration,
|
||||
unresolvedClipCount,
|
||||
clipCount: clips.length,
|
||||
},
|
||||
payload,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type GuardSpec = {
|
||||
id: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
pattern: RegExp;
|
||||
};
|
||||
|
||||
type GuardCheckResult = {
|
||||
passed: GuardSpec[];
|
||||
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: "child_timeline_activation",
|
||||
description: "Force root child timelines active before composition binding",
|
||||
filePath: "src/runtime/init.ts",
|
||||
pattern: /timelineWithPaused\.paused\(false\)/,
|
||||
},
|
||||
{
|
||||
id: "root_unusable_fallback",
|
||||
description: "Fallback to composite timeline when root duration is unusable",
|
||||
filePath: "src/runtime/init.ts",
|
||||
pattern:
|
||||
/if\s*\(\s*!isUsableTimelineDuration\(rootDurationSeconds\)\s*&&\s*rootChildCandidates\.length\s*>\s*0\s*\)/,
|
||||
},
|
||||
{
|
||||
id: "external_script_ordering",
|
||||
description: "Inject external composition scripts with deterministic ordering",
|
||||
filePath: "src/runtime/compositionLoader.ts",
|
||||
pattern: /injectedScript\.async\s*=\s*false;/,
|
||||
},
|
||||
{
|
||||
id: "external_script_load_wait",
|
||||
description: "Await external composition script load before continuing",
|
||||
filePath: "src/runtime/compositionLoader.ts",
|
||||
pattern: /await\s+waitForExternalScriptLoad\(injectedScript\);/,
|
||||
},
|
||||
];
|
||||
|
||||
function resolveFilePath(relativePath: string): string {
|
||||
return path.resolve(process.cwd(), relativePath);
|
||||
}
|
||||
|
||||
function checkGuards(guards: GuardSpec[]): GuardCheckResult {
|
||||
const passed: GuardSpec[] = [];
|
||||
const failed: GuardSpec[] = [];
|
||||
|
||||
for (const guard of guards) {
|
||||
const absolutePath = resolveFilePath(guard.filePath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
failed.push(guard);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(absolutePath, "utf8");
|
||||
if (guard.pattern.test(content)) {
|
||||
passed.push(guard);
|
||||
} else {
|
||||
failed.push(guard);
|
||||
}
|
||||
}
|
||||
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const result = checkGuards(GUARD_SPECS);
|
||||
if (result.failed.length === 0) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "runtime_preview_guards_lint_passed",
|
||||
checkedGuards: GUARD_SPECS.length,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "runtime_preview_guards_lint_failed",
|
||||
checkedGuards: GUARD_SPECS.length,
|
||||
missingGuards: result.failed.map((guard) => ({
|
||||
id: guard.id,
|
||||
filePath: guard.filePath,
|
||||
description: guard.description,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, extname, join, normalize, resolve } from "node:path";
|
||||
|
||||
const distDir = resolve(import.meta.dirname, "../dist");
|
||||
const runtimeExtensions = new Set([".js", ".mjs", ".cjs", ".json", ".wasm", ".node"]);
|
||||
|
||||
function listJavaScriptFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const fullPath = join(dir, entry);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listJavaScriptFiles(fullPath));
|
||||
} else if (entry.endsWith(".js")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function hasRuntimeExtension(specifier: string): boolean {
|
||||
return runtimeExtensions.has(extname(specifier));
|
||||
}
|
||||
|
||||
function isRelativeSpecifier(specifier: string): boolean {
|
||||
return specifier.startsWith("./") || specifier.startsWith("../");
|
||||
}
|
||||
|
||||
function resolveExistingJsSpecifier(fromFile: string, specifier: string): string | undefined {
|
||||
const absolute = resolve(dirname(fromFile), specifier);
|
||||
const jsFile = `${absolute}.js`;
|
||||
const indexFile = join(absolute, "index.js");
|
||||
|
||||
if (existsSync(jsFile)) return `${specifier}.js`;
|
||||
if (existsSync(indexFile)) return `${specifier}/index.js`;
|
||||
}
|
||||
|
||||
function resolveRuntimeSpecifier(fromFile: string, specifier: string): string {
|
||||
if (!isRelativeSpecifier(specifier)) return specifier;
|
||||
if (hasRuntimeExtension(specifier)) return specifier;
|
||||
|
||||
return resolveExistingJsSpecifier(fromFile, specifier) ?? specifier;
|
||||
}
|
||||
|
||||
function rewriteSpecifiers(filePath: string, source: string): string {
|
||||
const patterns = [
|
||||
/(from\s+["'])(\.\.?\/[^"']+)(["'])/g,
|
||||
/(import\s+["'])(\.\.?\/[^"']+)(["'])/g,
|
||||
/(import\(\s*["'])(\.\.?\/[^"']+)(["']\s*\))/g,
|
||||
];
|
||||
|
||||
return patterns.reduce(
|
||||
(nextSource, pattern) =>
|
||||
nextSource.replace(pattern, (_match, prefix: string, specifier: string, suffix: string) => {
|
||||
return `${prefix}${resolveRuntimeSpecifier(filePath, specifier)}${suffix}`;
|
||||
}),
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
let changed = 0;
|
||||
for (const filePath of listJavaScriptFiles(distDir)) {
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
const rewritten = rewriteSpecifiers(filePath, source);
|
||||
if (rewritten !== source) {
|
||||
writeFileSync(filePath, rewritten);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "core_esm_extensions_rewritten",
|
||||
distDir: normalize(distDir),
|
||||
changedFiles: changed,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { lintHyperframeHtml } from "@hyperframes/lint";
|
||||
|
||||
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>`;
|
||||
|
||||
async function testCleanFixturePasses() {
|
||||
const result = await lintHyperframeHtml(VALID_COMPOSITION, { filePath: "valid.html" });
|
||||
|
||||
assert.equal(result.ok, true, "valid composition should pass without lint errors");
|
||||
assert.equal(result.errorCount, 0, "valid composition should have zero lint errors");
|
||||
}
|
||||
|
||||
async function testDetectsMissingCompositionHostId() {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="root" data-width="1080" data-height="1920">
|
||||
<div data-composition-src="compositions/overlays.html"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["root"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</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"));
|
||||
}
|
||||
|
||||
async function testDetectsOverlappingGsapTweens() {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="main" data-composition-id="main" data-width="1080" data-height="1920">
|
||||
<div id="frame"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#frame", { y: 15, duration: 2.5, repeat: 1, yoyo: true }, 1.5);
|
||||
tl.to("#frame", { y: 400, duration: 1.2 }, 4.5);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const overlapFinding = result.findings.find(
|
||||
(finding) => finding.code === "overlapping_gsap_tweens",
|
||||
);
|
||||
|
||||
assert.ok(overlapFinding, "expected an overlapping GSAP tween warning");
|
||||
assert.equal(overlapFinding?.severity, "warning");
|
||||
}
|
||||
|
||||
function testCliJsonOutput() {
|
||||
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);
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(typeof payload.errorCount, "number");
|
||||
assert.ok(Array.isArray(payload.findings));
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await testCleanFixturePasses();
|
||||
await testDetectsMissingCompositionHostId();
|
||||
await testDetectsOverlappingGsapTweens();
|
||||
testCliJsonOutput();
|
||||
console.log("hyperframe linter tests passed");
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { buildHyperframesRuntimeScript } from "../src/inline-scripts/hyperframesRuntime.engine";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = buildHyperframesRuntimeScript();
|
||||
assert(baseline !== null, "buildHyperframesRuntimeScript() returned null — entry.ts not found");
|
||||
const parityEnabled = buildHyperframesRuntimeScript({ defaultParityMode: true });
|
||||
assert(parityEnabled !== null, "Parity-enabled build returned null");
|
||||
const parityDisabled = buildHyperframesRuntimeScript({ defaultParityMode: false });
|
||||
assert(parityDisabled !== null, "Parity-disabled build returned null");
|
||||
const withSourceUrl = buildHyperframesRuntimeScript({
|
||||
sourceUrl: "hyperframe.runtime.iife.js",
|
||||
});
|
||||
assert(withSourceUrl !== null, "Build with sourceUrl returned null");
|
||||
|
||||
assert(baseline.includes("window.__player"), "Baseline runtime should include player contract");
|
||||
assert(parityEnabled.length > 0, "Parity-enabled build should produce non-empty runtime source");
|
||||
assert(parityDisabled.length > 0, "Parity-disabled build should produce non-empty runtime source");
|
||||
assert(
|
||||
withSourceUrl.includes("//# sourceURL=hyperframe.runtime.iife.js"),
|
||||
"Build with sourceUrl should append sourceURL comment",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_behavior_verified",
|
||||
baselineBytes: baseline.length,
|
||||
parityEnabledBytes: parityEnabled.length,
|
||||
parityDisabledBytes: parityDisabled.length,
|
||||
sourceUrlBytes: withSourceUrl.length,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadHyperframeRuntimeSource } from "../src/inline-scripts/hyperframe";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeSource = loadHyperframeRuntimeSource();
|
||||
assert(runtimeSource !== null, "loadHyperframeRuntimeSource() returned null — entry.ts not found");
|
||||
|
||||
const requiredSnippets = [
|
||||
"window.__player",
|
||||
"window.__playerReady",
|
||||
"window.__renderReady",
|
||||
"hf-preview",
|
||||
"hf-parent",
|
||||
"renderSeek",
|
||||
"__hyperframes",
|
||||
"fitTextFontSize",
|
||||
];
|
||||
|
||||
for (const snippet of requiredSnippets) {
|
||||
assert(runtimeSource.includes(snippet), `Runtime contract snippet missing: ${snippet}`);
|
||||
}
|
||||
|
||||
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
const manifestPath = resolve(scriptDir, "../dist/hyperframe.manifest.json");
|
||||
try {
|
||||
const manifestRaw = readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(manifestRaw) as { artifacts?: { iife?: string; esm?: string } };
|
||||
assert(Boolean(manifest.artifacts?.iife), "Manifest is missing iife artifact");
|
||||
assert(Boolean(manifest.artifacts?.esm), "Manifest is missing esm artifact");
|
||||
} catch {
|
||||
// Build may not have run yet; contract-only checks above still provide signal.
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_contract_verified",
|
||||
requiredSnippetsChecked: requiredSnippets.length,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const initPath = resolve(scriptDir, "../src/runtime/init.ts");
|
||||
const timelinePath = resolve(scriptDir, "../src/runtime/timeline.ts");
|
||||
|
||||
const initSource = readFileSync(initPath, "utf8");
|
||||
const timelineSource = readFileSync(timelinePath, "utf8");
|
||||
|
||||
// Guard against regressions where preview duration gets capped by earliest video.
|
||||
assert(
|
||||
!initSource.includes("resolveMainVideoDurationSeconds"),
|
||||
"init.ts should not use first-video duration helper",
|
||||
);
|
||||
assert(
|
||||
!initSource.includes("Math.max(0, Math.min(safeDuration, mediaFloor))"),
|
||||
"init.ts should not hard-clamp safe duration to media floor",
|
||||
);
|
||||
assert(
|
||||
initSource.includes("resolveMediaWindowDurationSeconds"),
|
||||
"init.ts should compute media window duration across timed media",
|
||||
);
|
||||
|
||||
// Timeline payload windowing should also avoid first-video truncation.
|
||||
assert(
|
||||
!timelineSource.includes("resolveMainVideoWindowEndSeconds"),
|
||||
"timeline.ts should not use first-video window end helper",
|
||||
);
|
||||
assert(
|
||||
timelineSource.includes("resolveMediaWindowEndSeconds"),
|
||||
"timeline.ts should compute media window end across timed media",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_duration_guards_verified",
|
||||
initPath,
|
||||
timelinePath,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { HYPERFRAME_RUNTIME_ARTIFACTS } from "../src/inline-scripts/hyperframe";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const scriptsDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
const distDir = resolve(scriptsDir, "../dist");
|
||||
const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife);
|
||||
const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest);
|
||||
|
||||
const iifeSource = readFileSync(iifePath, "utf8");
|
||||
const manifestRaw = readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(manifestRaw) as {
|
||||
sha256?: string;
|
||||
artifacts?: { iife?: string; esm?: string };
|
||||
};
|
||||
|
||||
assert(iifeSource.length > 0, "IIFE runtime artifact is empty");
|
||||
assert(
|
||||
manifest.artifacts?.iife === HYPERFRAME_RUNTIME_ARTIFACTS.iife,
|
||||
"Manifest iife artifact name is not strict expected value",
|
||||
);
|
||||
assert(
|
||||
manifest.artifacts?.esm === HYPERFRAME_RUNTIME_ARTIFACTS.esm,
|
||||
"Manifest esm artifact name is not strict expected value",
|
||||
);
|
||||
assert(Boolean(manifest.artifacts?.iife), "Manifest missing iife artifact entry");
|
||||
assert(Boolean(manifest.artifacts?.esm), "Manifest missing esm artifact entry");
|
||||
|
||||
const runtimeSha = createHash("sha256").update(iifeSource, "utf8").digest("hex");
|
||||
assert(runtimeSha === manifest.sha256, "Manifest sha256 does not match runtime artifact");
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_parity_verified",
|
||||
bytes: iifeSource.length,
|
||||
sha256: runtimeSha,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_HOSTS = new Set(["runtime.example.com"]);
|
||||
const ALLOWED_PATH_PREFIX = "/static/hyperframes-runtime/";
|
||||
|
||||
function isAllowedRuntimeUrl(candidate: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
return (
|
||||
parsed.protocol === "https:" &&
|
||||
ALLOWED_HOSTS.has(parsed.hostname.toLowerCase()) &&
|
||||
parsed.pathname.startsWith(ALLOWED_PATH_PREFIX) &&
|
||||
parsed.pathname.endsWith(".js")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGuardedPreviewMessage(data: unknown): boolean {
|
||||
if (!data || typeof data !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = data as { source?: unknown; type?: unknown };
|
||||
const source = typeof record.source === "string" ? record.source : null;
|
||||
const type = typeof record.type === "string" ? record.type : null;
|
||||
const guardedTypes = new Set([
|
||||
"state",
|
||||
"timeline",
|
||||
"element-picked",
|
||||
"element-picked-many",
|
||||
"element-pick-candidates",
|
||||
"pick-mode-cancelled",
|
||||
]);
|
||||
if (!type || !guardedTypes.has(type)) {
|
||||
return false;
|
||||
}
|
||||
return source === "hf-preview";
|
||||
}
|
||||
|
||||
const allowedRuntimeFixtures = [
|
||||
"https://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.js",
|
||||
"https://runtime.example.com/static/hyperframes-runtime/v2026.02.20/hyperframe.runtime.iife.js",
|
||||
];
|
||||
|
||||
const blockedRuntimeFixtures = [
|
||||
"http://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.js",
|
||||
"javascript:alert(1)",
|
||||
"data:text/javascript,alert(1)",
|
||||
"https://evil.example/static/hyperframes-runtime/hyperframe.runtime.iife.js",
|
||||
"https://runtime.example.com/static/other/hyperframe.runtime.iife.js",
|
||||
"https://runtime.example.com/static/hyperframes-runtime/hyperframe.runtime.iife.css",
|
||||
];
|
||||
|
||||
for (const fixture of allowedRuntimeFixtures) {
|
||||
assert(isAllowedRuntimeUrl(fixture), `Expected runtime URL to be allowed: ${fixture}`);
|
||||
}
|
||||
|
||||
for (const fixture of blockedRuntimeFixtures) {
|
||||
assert(!isAllowedRuntimeUrl(fixture), `Expected runtime URL to be blocked: ${fixture}`);
|
||||
}
|
||||
|
||||
const allowedMessages = [
|
||||
{ type: "state", source: "hf-preview" },
|
||||
{ type: "timeline", source: "hf-preview" },
|
||||
{ type: "element-picked", source: "hf-preview" },
|
||||
];
|
||||
const blockedMessages = [
|
||||
{ type: "state", source: "hf-parent" },
|
||||
{ type: "timeline", source: "evil-origin" },
|
||||
{ type: "element-picked", source: "hf-parent" },
|
||||
{ type: "pick-mode-cancelled", source: null },
|
||||
{ type: "unknown-type", source: "hf-preview" },
|
||||
];
|
||||
|
||||
for (const fixture of allowedMessages) {
|
||||
assert(
|
||||
isGuardedPreviewMessage(fixture),
|
||||
`Expected message fixture to pass guard: ${JSON.stringify(fixture)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const fixture of blockedMessages) {
|
||||
assert(
|
||||
!isGuardedPreviewMessage(fixture),
|
||||
`Expected message fixture to fail guard: ${JSON.stringify(fixture)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_security_fixtures_verified",
|
||||
allowedRuntimeFixtures: allowedRuntimeFixtures.length,
|
||||
blockedRuntimeFixtures: blockedRuntimeFixtures.length,
|
||||
allowedMessages: allowedMessages.length,
|
||||
blockedMessages: blockedMessages.length,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createGsapAdapter } from "../src/runtime/adapters/gsap";
|
||||
import { createRuntimePlayer } from "../src/runtime/player";
|
||||
import type { RuntimeTimelineLike } from "../src/runtime/types";
|
||||
|
||||
type Call = {
|
||||
method: "pause" | "seek" | "totalTime";
|
||||
time?: number;
|
||||
suppressEvents?: boolean;
|
||||
};
|
||||
|
||||
function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: RuntimeTimelineLike } {
|
||||
const calls: Call[] = [];
|
||||
const timeline: RuntimeTimelineLike = {
|
||||
play: () => undefined,
|
||||
pause: () => {
|
||||
calls.push({ method: "pause" });
|
||||
},
|
||||
seek: (timeSeconds: number, suppressEvents?: boolean) => {
|
||||
calls.push({ method: "seek", time: timeSeconds, suppressEvents });
|
||||
},
|
||||
time: () => 0,
|
||||
duration: () => 12,
|
||||
add: () => undefined,
|
||||
paused: () => undefined,
|
||||
set: () => undefined,
|
||||
};
|
||||
if (withTotalTime) {
|
||||
timeline.totalTime = (timeSeconds: number, suppressEvents?: boolean) => {
|
||||
calls.push({ method: "totalTime", time: timeSeconds, suppressEvents });
|
||||
};
|
||||
}
|
||||
return { calls, timeline };
|
||||
}
|
||||
|
||||
function createPlayer(timeline: RuntimeTimelineLike) {
|
||||
const deterministicSeekCalls: number[] = [];
|
||||
const syncMediaCalls: number[] = [];
|
||||
const renderFrameSeekCalls: number[] = [];
|
||||
const player = createRuntimePlayer({
|
||||
getTimeline: () => timeline,
|
||||
setTimeline: () => undefined,
|
||||
getIsPlaying: () => false,
|
||||
setIsPlaying: () => undefined,
|
||||
getPlaybackRate: () => 1,
|
||||
setPlaybackRate: () => undefined,
|
||||
getCanonicalFps: () => 30,
|
||||
onSyncMedia: (timeSeconds) => {
|
||||
syncMediaCalls.push(timeSeconds);
|
||||
},
|
||||
onStatePost: () => undefined,
|
||||
onDeterministicSeek: (timeSeconds) => {
|
||||
deterministicSeekCalls.push(timeSeconds);
|
||||
},
|
||||
onDeterministicPause: () => undefined,
|
||||
onDeterministicPlay: () => undefined,
|
||||
onRenderFrameSeek: (timeSeconds) => {
|
||||
renderFrameSeekCalls.push(timeSeconds);
|
||||
},
|
||||
onShowNativeVideos: () => undefined,
|
||||
getSafeDuration: () => 12,
|
||||
});
|
||||
return { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls };
|
||||
}
|
||||
|
||||
function testSeekUsesDeterministicGsapPath(): void {
|
||||
const { calls, timeline } = createTimeline(true);
|
||||
const { player, deterministicSeekCalls, syncMediaCalls, renderFrameSeekCalls } =
|
||||
createPlayer(timeline);
|
||||
const quantizedTime = 2;
|
||||
|
||||
player.seek(2.017);
|
||||
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[{ method: "pause" }, { method: "totalTime", time: quantizedTime, suppressEvents: false }],
|
||||
"player.seek() should use quantized totalTime() when available",
|
||||
);
|
||||
assert.deepEqual(
|
||||
deterministicSeekCalls,
|
||||
[quantizedTime],
|
||||
"player.seek() should notify adapters with the quantized time",
|
||||
);
|
||||
assert.deepEqual(syncMediaCalls, [quantizedTime], "media sync should use quantized time");
|
||||
assert.deepEqual(
|
||||
renderFrameSeekCalls,
|
||||
[quantizedTime],
|
||||
"render frame seek should use quantized time",
|
||||
);
|
||||
}
|
||||
|
||||
function testGsapAdapterPreservesTotalTime(): void {
|
||||
const { calls, timeline } = createTimeline(true);
|
||||
const adapter = createGsapAdapter({ getTimeline: () => timeline });
|
||||
|
||||
const seekTime = 2.033333333333333;
|
||||
adapter.seek({ time: seekTime });
|
||||
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[
|
||||
{ method: "pause" },
|
||||
// Nudge to force GSAP 3.x dirty state before the real seek
|
||||
{ method: "totalTime", time: seekTime + 0.001, suppressEvents: true },
|
||||
{ method: "totalTime", time: seekTime, suppressEvents: false },
|
||||
],
|
||||
"GSAP adapter should nudge then seek via totalTime() (not downgrade to seek())",
|
||||
);
|
||||
}
|
||||
|
||||
function testGsapAdapterFallsBackToSeek(): void {
|
||||
const { calls, timeline } = createTimeline(false);
|
||||
const adapter = createGsapAdapter({ getTimeline: () => timeline });
|
||||
|
||||
adapter.seek({ time: 1.5 });
|
||||
|
||||
assert.deepEqual(
|
||||
calls,
|
||||
[{ method: "pause" }, { method: "seek", time: 1.5, suppressEvents: false }],
|
||||
"GSAP adapter should keep working with timelines that only expose seek()",
|
||||
);
|
||||
}
|
||||
|
||||
testSeekUsesDeterministicGsapPath();
|
||||
testGsapAdapterPreservesTotalTime();
|
||||
testGsapAdapterFallsBackToSeek();
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "hyperframe_runtime_seek_verified",
|
||||
assertions: 3,
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user