fix: add media rendering guardrails to prevent silent failures

Lint rules:
- media_missing_id (error): audio/video with data-start but no id
- media_missing_src (error): media with id but no src
- media_preload_none (warning): preload="none" blocks the renderer
- video_nested_in_timed_element upgraded from warning to error

Compiler:
- Strip preload="none" from media during compilation
- Fire-and-forget keyframe interval + VFR detection via ffprobe
- Warn on sparse keyframes (>2s) and variable frame rate videos

CLI:
- Shared lintProject() lints index.html + compositions/*.html
- Shared formatLintFindings() used by lint, render, and dev commands
- Pre-render lint: warns by default, --strict blocks on errors,
  --strict-all blocks on errors + warnings
- Pre-dev lint: prints issues for agents before starting studio
- Render orchestrator hints --workers 1 on parallel capture timeout

Refactor:
- Extract runFfprobe() + parseProbeJson() (~80 lines deduped)
- Extract shouldBlockRender() for testable strict flag logic
- Extract formatLintFindings() (3 consumers unified)
This commit is contained in:
Vance Ingalls
2026-03-30 10:00:32 -07:00
parent 808d196fe0
commit 43337ae224
15 changed files with 7333 additions and 172 deletions
+3 -1
View File
@@ -15,6 +15,7 @@
],
"type": "module",
"scripts": {
"test": "vitest run",
"dev": "tsx src/cli.ts",
"build": "bun run build:fonts && bun run build:studio && tsup && bun run build:runtime && bun run build:copy",
"build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts",
@@ -52,7 +53,8 @@
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=22"
+15
View File
@@ -7,6 +7,8 @@ import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
/**
* Try to start a server on the given port, auto-incrementing up to maxAttempts
@@ -71,6 +73,19 @@ export default defineCommand({
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir);
// Lint before starting — surface issues for the agent to fix.
// dev.ts doesn't use resolveProject() because it needs to proceed even without index.html.
const indexPath = join(dir, "index.html");
if (existsSync(indexPath)) {
const project = { dir, name: projectName, indexPath };
const lintResult = lintProject(project);
if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
console.log();
for (const line of formatLintFindings(lintResult)) console.log(line);
console.log();
}
}
if (isDevMode()) {
return runDevMode(dir, projectName);
}
+18 -51
View File
@@ -1,11 +1,8 @@
import { defineCommand } from "citty";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { lintHyperframeHtml } from "@hyperframes/core/lint";
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
import { walkDir } from "@hyperframes/core/studio-api";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { withMeta } from "../utils/updateCheck.js";
export default defineCommand({
@@ -16,62 +13,32 @@ export default defineCommand({
},
async run({ args }) {
const project = resolveProject(args.dir);
const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
const allFindings: (HyperframeLintFinding & { file: string })[] = [];
let totalErrors = 0;
let totalWarnings = 0;
for (const file of htmlFiles) {
const html = readFileSync(join(project.dir, file), "utf-8");
const result = lintHyperframeHtml(html, { filePath: file });
for (const f of result.findings) {
allFindings.push({ ...f, file });
}
totalErrors += result.errorCount;
totalWarnings += result.warningCount;
}
const lintResult = lintProject(project);
if (args.json) {
console.log(
JSON.stringify(
withMeta({
ok: totalErrors === 0,
findings: allFindings,
errorCount: totalErrors,
warningCount: totalWarnings,
filesScanned: htmlFiles.length,
}),
null,
2,
),
);
process.exit(totalErrors > 0 ? 1 : 0);
const combined = {
ok: lintResult.totalErrors === 0,
errorCount: lintResult.totalErrors,
warningCount: lintResult.totalWarnings,
findings: lintResult.results.flatMap((r) => r.result.findings),
};
console.log(JSON.stringify(withMeta(combined), null, 2));
process.exit(combined.ok ? 0 : 1);
}
console.log(
`${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`,
);
const fileCount = lintResult.results.length;
const fileLabel = fileCount === 1 ? lintResult.results[0]!.file : `${fileCount} files`;
console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`);
console.log();
if (allFindings.length === 0) {
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) {
console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`);
return;
}
for (const finding of allFindings) {
const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠");
const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
console.log(
`${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`,
);
if (finding.fixHint) {
console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
}
}
const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true });
for (const line of lines) console.log(line);
const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇");
console.log(`\n${summaryIcon} ${totalErrors} error(s), ${totalWarnings} warning(s)`);
process.exit(totalErrors > 0 ? 1 : 0);
process.exit(lintResult.totalErrors > 0 ? 1 : 0);
},
});
+39
View File
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, statSync } from "node:fs";
import { cpus, freemem } from "node:os";
import { resolve, dirname, join } from "node:path";
import { resolveProject } from "../utils/project.js";
import { lintProject, shouldBlockRender } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
@@ -75,6 +77,16 @@ Examples:
description: "Suppress verbose output",
default: false,
},
strict: {
type: "boolean",
description: "Fail render on lint errors",
default: false,
},
"strict-all": {
type: "boolean",
description: "Fail render on lint errors AND warnings",
default: false,
},
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
@@ -131,6 +143,8 @@ Examples:
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const quiet = args.quiet ?? false;
const strictAll = args["strict-all"] ?? false;
const strictErrors = (args.strict ?? false) || strictAll;
// ── Print render plan ─────────────────────────────────────────────────
const workerCount = workers ?? defaultWorkerCount();
@@ -193,6 +207,31 @@ Examples:
}
}
// ── Pre-render lint ──────────────────────────────────────────────────
{
const lintResult = lintProject(project);
if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
console.log("");
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
if (
shouldBlockRender(
strictErrors,
strictAll,
lintResult.totalErrors,
lintResult.totalWarnings,
)
) {
const mode = strictAll ? "--strict-all" : "--strict";
console.log("");
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
console.log("");
process.exit(1);
}
console.log(c.dim(" Continuing render despite lint issues. Use --strict to block."));
console.log("");
}
}
// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
+51
View File
@@ -0,0 +1,51 @@
import { c } from "../ui/colors.js";
import type { ProjectLintResult } from "./lintProject.js";
export interface LintFormatOptions {
/** Show elementId in brackets after the code (default: true) */
showElementId?: boolean;
/** Show summary line with error/warning counts (default: false) */
showSummary?: boolean;
/** Group errors before warnings per file (default: false — interleaved) */
errorsFirst?: boolean;
}
/**
* Format lint findings for console output. Used by lint, render, and dev commands.
*/
export function formatLintFindings(
{ results, totalErrors, totalWarnings }: ProjectLintResult,
options: LintFormatOptions = {},
): string[] {
const { showElementId = true, showSummary = false, errorsFirst = false } = options;
const lines: string[] = [];
const multiFile = results.length > 1;
for (const { file, result } of results) {
if (result.findings.length === 0) continue;
const format = (finding: (typeof result.findings)[0]) => {
const prefix = finding.severity === "error" ? c.error("✗") : c.warn("⚠");
const fileLabel = multiFile ? c.dim(`[${file}] `) : "";
const loc =
showElementId && finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : "";
lines.push(` ${prefix} ${fileLabel}${c.bold(finding.code)}${loc}: ${finding.message}`);
if (finding.fixHint) lines.push(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
};
if (errorsFirst) {
for (const f of result.findings) if (f.severity === "error") format(f);
for (const f of result.findings) if (f.severity === "warning") format(f);
} else {
for (const f of result.findings) format(f);
}
}
if (showSummary) {
const icon = totalErrors > 0 ? c.error("◇") : c.success("◇");
lines.push("");
lines.push(`${icon} ${totalErrors} error(s), ${totalWarnings} warning(s)`);
}
return lines;
}
+169
View File
@@ -0,0 +1,169 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { lintProject, shouldBlockRender } from "./lintProject.js";
import type { ProjectDir } from "./project.js";
function tmpProject(name: string): string {
const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function validHtml(compId = "main"): string {
return `<html><body>
<div data-composition-id="${compId}" data-width="1920" data-height="1080"></div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
function htmlWithMissingMediaId(): string {
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
function htmlWithPreloadNone(): string {
return `<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["captions"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
let dirs: string[] = [];
function makeProject(indexHtml: string, subComps?: Record<string, string>): ProjectDir {
const dir = tmpProject("lint");
dirs.push(dir);
writeFileSync(join(dir, "index.html"), indexHtml);
if (subComps) {
const compsDir = join(dir, "compositions");
mkdirSync(compsDir, { recursive: true });
for (const [name, html] of Object.entries(subComps)) {
writeFileSync(join(compsDir, name), html);
}
}
return { dir, name: "test-project", indexPath: join(dir, "index.html") };
}
afterEach(() => {
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs = [];
});
describe("lintProject", () => {
it("returns zero errors/warnings for a clean project", () => {
const project = makeProject(validHtml());
const { totalErrors, totalWarnings, results } = lintProject(project);
expect(totalErrors).toBe(0);
expect(totalWarnings).toBe(0);
expect(results).toHaveLength(1);
expect(results[0]!.file).toBe("index.html");
});
it("detects errors in index.html", () => {
const project = makeProject(htmlWithMissingMediaId());
const { totalErrors, results } = lintProject(project);
expect(totalErrors).toBeGreaterThan(0);
const mediaFinding = results[0]!.result.findings.find((f) => f.code === "media_missing_id");
expect(mediaFinding).toBeDefined();
});
it("lints sub-compositions in compositions/ directory", () => {
const project = makeProject(validHtml(), {
"captions.html": htmlWithMissingMediaId(),
});
const { totalErrors, results } = lintProject(project);
expect(results).toHaveLength(2);
expect(results[1]!.file).toBe("compositions/captions.html");
expect(totalErrors).toBeGreaterThan(0);
const subFindings = results[1]!.result.findings;
expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true);
});
it("aggregates errors across index.html and sub-compositions", () => {
const project = makeProject(htmlWithMissingMediaId(), {
"overlay.html": htmlWithMissingMediaId(),
});
const { totalErrors, results } = lintProject(project);
expect(results).toHaveLength(2);
// Both files have media_missing_id errors
const rootErrors = results[0]!.result.errorCount;
const subErrors = results[1]!.result.errorCount;
expect(totalErrors).toBe(rootErrors + subErrors);
});
it("aggregates warnings from sub-compositions", () => {
const project = makeProject(validHtml(), {
"captions.html": htmlWithPreloadNone(),
});
const { totalWarnings, results } = lintProject(project);
expect(results).toHaveLength(2);
expect(totalWarnings).toBeGreaterThan(0);
const preloadWarning = results[1]!.result.findings.find((f) => f.code === "media_preload_none");
expect(preloadWarning).toBeDefined();
});
it("handles project with no compositions/ directory", () => {
const project = makeProject(validHtml());
// No compositions/ dir created
const { results } = lintProject(project);
expect(results).toHaveLength(1);
});
it("ignores non-HTML files in compositions/", () => {
const project = makeProject(validHtml(), {
"captions.html": validHtml("captions"),
});
// Add a non-HTML file
writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html");
const { results } = lintProject(project);
expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt
});
});
describe("shouldBlockRender", () => {
it("default: does not block on errors", () => {
expect(shouldBlockRender(false, false, 5, 0)).toBe(false);
});
it("default: does not block on warnings", () => {
expect(shouldBlockRender(false, false, 0, 3)).toBe(false);
});
it("--strict: blocks on errors", () => {
expect(shouldBlockRender(true, false, 1, 0)).toBe(true);
});
it("--strict: does not block on warnings only", () => {
expect(shouldBlockRender(true, false, 0, 5)).toBe(false);
});
it("--strict-all: blocks on errors", () => {
expect(shouldBlockRender(true, true, 1, 0)).toBe(true);
});
it("--strict-all: blocks on warnings", () => {
expect(shouldBlockRender(true, true, 0, 1)).toBe(true);
});
it("--strict-all: does not block when clean", () => {
expect(shouldBlockRender(true, true, 0, 0)).toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
import type { ProjectDir } from "./project.js";
export interface ProjectLintResult {
results: Array<{ file: string; result: HyperframeLintResult }>;
totalErrors: number;
totalWarnings: number;
}
/**
* Lint the root index.html and all sub-compositions in the compositions/ directory.
* Returns aggregated results across all files.
*/
export function lintProject(project: ProjectDir): ProjectLintResult {
const results: Array<{ file: string; result: HyperframeLintResult }> = [];
let totalErrors = 0;
let totalWarnings = 0;
// Lint root composition
const rootHtml = readFileSync(project.indexPath, "utf-8");
const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath });
results.push({ file: "index.html", result: rootResult });
totalErrors += rootResult.errorCount;
totalWarnings += rootResult.warningCount;
// Lint sub-compositions in compositions/ directory
const compositionsDir = resolve(project.dir, "compositions");
if (existsSync(compositionsDir)) {
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
for (const file of files) {
const filePath = join(compositionsDir, file);
const html = readFileSync(filePath, "utf-8");
const result = lintHyperframeHtml(html, { filePath });
results.push({ file: `compositions/${file}`, result });
totalErrors += result.errorCount;
totalWarnings += result.warningCount;
}
}
return { results, totalErrors, totalWarnings };
}
/**
* Determine whether a render should be blocked based on lint results and strict mode.
* --strict blocks on errors; --strict-all blocks on errors or warnings.
*/
export function shouldBlockRender(
strictErrors: boolean,
strictAll: boolean,
totalErrors: number,
totalWarnings: number,
): boolean {
return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});
+332 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
describe("lintHyperframeHtml", () => {
const validComposition = `
@@ -130,4 +130,335 @@ describe("lintHyperframeHtml", () => {
);
expect(missing).toHaveLength(0);
});
it("reports error when timeline registry is assigned without initializing", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("without initializing");
});
it("does not flag timeline assignment when init guard is present", () => {
const result = lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
it("reports error for audio with data-start but no id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("SILENT");
});
it("reports error for video with data-start but no id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("FROZEN");
});
it("does not flag media elements that have id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_preload_none");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("reports error for media with id but no src", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
});
describe("lintScriptUrls", () => {
it("reports error for script URL returning non-2xx", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://unpkg.com/@hyperframe/player@latest/dist/player.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
const finding = findings.find((f) => f.code === "inaccessible_script_url");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("404");
vi.unstubAllGlobals();
});
it("reports error for unreachable script URL", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://example.invalid/nonexistent.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
const finding = findings.find((f) => f.code === "inaccessible_script_url");
expect(finding).toBeDefined();
vi.unstubAllGlobals();
});
it("does not flag accessible script URLs", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
expect(findings.length).toBe(0);
vi.unstubAllGlobals();
});
it("skips inline scripts without src", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>console.log("inline")</script>
</body></html>`;
const findings = await lintScriptUrls(html);
expect(findings.length).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
// ── gsap_css_transform_conflict ──────────────────────────────────────────
it("warns when tl.to animates x on an element with CSS translateX", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title" style=""></div>
</div>
<style>
#title { position: absolute; top: 240px; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#title");
expect(finding?.fixHint).toMatch(/fromTo/);
expect(finding?.fixHint).toMatch(/xPercent/);
});
it("warns when tl.to animates scale on an element with CSS scale transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: scale(0.8); opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, scale: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#hero");
});
it("does NOT warn when tl.to targets element without CSS transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card"></div>
</div>
<style>
#card { position: absolute; top: 100px; left: 100px; opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 0, opacity: 1, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title"></div>
</div>
<style>
#title { position: absolute; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { xPercent: -50, x: -1000, opacity: 0 }, { xPercent: -50, x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: translateX(-50%) scale(0.8); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 0, scale: 1, opacity: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/);
});
});
describe("template_literal_selector rule", () => {
it("reports error when querySelector uses template literal variable", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="chart"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const compId = "main";
const el = document.querySelector(\`[data-composition-id="\${compId}"] .chart\`);
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error for querySelectorAll with template literal variable", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const id = "main";
document.querySelectorAll(\`[data-composition-id="\${id}"] .item\`);
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeDefined();
});
it("does not report error for hardcoded querySelector strings", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="chart"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const el = document.querySelector('[data-composition-id="main"] .chart');
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeUndefined();
});
});
+42 -2
View File
@@ -379,8 +379,8 @@ export function lintHyperframeHtml(
if (!parentClosePattern.test(between)) {
pushFinding({
code: "video_nested_in_timed_element",
severity: "warning",
message: `<video> with data-start appears to be nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. This can break media sync.`,
severity: "error",
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
@@ -459,6 +459,46 @@ export function lintHyperframeHtml(
}
}
// #3.8: Media element checks — missing id, missing src, preload="none"
// The runtime discovers media via querySelectorAll("video[data-start]") which
// works fine for preview. But the renderer uses querySelectorAll("video[id][src]")
// — without id, elements are silently skipped (no audio, frozen video).
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const hasDataStart = readAttr(tag.raw, "data-start");
const hasId = readAttr(tag.raw, "id");
const hasSrc = readAttr(tag.raw, "src");
if (hasDataStart && !hasId) {
pushFinding({
code: "media_missing_id",
severity: "error",
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasDataStart && hasId && !hasSrc) {
pushFinding({
code: "media_missing_src",
severity: "error",
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
elementId: hasId,
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
snippet: truncateSnippet(tag.raw),
});
}
if (readAttr(tag.raw, "preload") === "none") {
pushFinding({
code: "media_preload_none",
severity: "warning",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
elementId: hasId || undefined,
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
snippet: truncateSnippet(tag.raw),
});
}
}
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
// Skip: elements with data-composition-id (managed by runtime), elements with
// opacity:0 in style (will be animated in by GSAP), and composition host elements.
+2
View File
@@ -146,8 +146,10 @@ export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes
export {
extractVideoMetadata,
extractAudioMetadata,
analyzeKeyframeIntervals,
type VideoMetadata,
type AudioMetadata,
type KeyframeAnalysis,
} from "./utils/ffprobe.js";
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
+153 -116
View File
@@ -1,5 +1,44 @@
import { spawn } from "child_process";
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
function runFfprobe(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn("ffprobe", args);
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (code !== 0) {
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
} else {
resolve(stdout);
}
});
proc.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
} else {
reject(err);
}
});
});
}
function parseProbeJson(stdout: string): FFProbeOutput {
try {
return JSON.parse(stdout);
} catch (e) {
throw new Error(
`[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`,
);
}
}
const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
@@ -10,6 +49,8 @@ export interface VideoMetadata {
fps: number;
videoCodec: string;
hasAudio: boolean;
/** True when r_frame_rate and avg_frame_rate differ significantly (>10%), indicating variable frame rate. */
isVFR: boolean;
}
export interface AudioMetadata {
@@ -54,12 +95,10 @@ function parseFrameRate(frameRateStr: string | undefined): number {
export async function extractVideoMetadata(filePath: string): Promise<VideoMetadata> {
const cached = videoMetadataCache.get(filePath);
if (cached) {
return cached;
}
if (cached) return cached;
const probePromise = new Promise<VideoMetadata>((resolve, reject) => {
const args = [
const probePromise = (async (): Promise<VideoMetadata> => {
const stdout = await runFfprobe([
"-v",
"quiet",
"-print_format",
@@ -67,64 +106,28 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
"-show_format",
"-show_streams",
filePath,
];
]);
const output = parseProbeJson(stdout);
const videoStream = output.streams.find((s) => s.codec_type === "video");
if (!videoStream) throw new Error("[FFmpeg] No video stream found");
const ffprobe = spawn("ffprobe", args);
let stdout = "";
let stderr = "";
const rFps = parseFrameRate(videoStream.r_frame_rate);
const avgFps = parseFrameRate(videoStream.avg_frame_rate);
const fps = avgFps || rFps;
// VFR: r_frame_rate (max/nominal) differs from avg_frame_rate (actual average) by >10%
const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
ffprobe.stdout.on("data", (data) => {
stdout += data.toString();
});
ffprobe.stderr.on("data", (data) => {
stderr += data.toString();
});
return {
durationSeconds: output.format.duration ? parseFloat(output.format.duration) : 0,
width: videoStream.width || 0,
height: videoStream.height || 0,
fps,
videoCodec: videoStream.codec_name || "unknown",
hasAudio: output.streams.some((s) => s.codec_type === "audio"),
isVFR,
};
})();
ffprobe.on("close", (code) => {
if (code !== 0) {
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
return;
}
try {
const output: FFProbeOutput = JSON.parse(stdout);
const videoStream = output.streams.find((s) => s.codec_type === "video");
if (!videoStream) {
reject(new Error("[FFmpeg] No video stream found"));
return;
}
const hasAudio = output.streams.some((s) => s.codec_type === "audio");
const fps =
parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
const metadata: VideoMetadata = {
durationSeconds,
width: videoStream.width || 0,
height: videoStream.height || 0,
fps,
videoCodec: videoStream.codec_name || "unknown",
hasAudio,
};
resolve(metadata);
} catch (parseError: unknown) {
reject(
new Error(
`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
),
);
}
});
ffprobe.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
} else {
reject(err);
}
});
});
videoMetadataCache.set(filePath, probePromise);
probePromise.catch(() => {
if (videoMetadataCache.get(filePath) === probePromise) {
@@ -136,12 +139,10 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
export async function extractAudioMetadata(filePath: string): Promise<AudioMetadata> {
const cached = audioMetadataCache.get(filePath);
if (cached) {
return cached;
}
if (cached) return cached;
const probePromise = new Promise<AudioMetadata>((resolve, reject) => {
const args = [
const probePromise = (async (): Promise<AudioMetadata> => {
const stdout = await runFfprobe([
"-v",
"quiet",
"-print_format",
@@ -149,60 +150,22 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
"-show_format",
"-show_streams",
filePath,
];
]);
const output = parseProbeJson(stdout);
const audioStream = output.streams.find((s) => s.codec_type === "audio");
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
const ffprobe = spawn("ffprobe", args);
let stdout = "";
let stderr = "";
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
ffprobe.stdout.on("data", (data) => {
stdout += data.toString();
});
ffprobe.stderr.on("data", (data) => {
stderr += data.toString();
});
return {
durationSeconds,
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
channels: audioStream.channels || 2,
audioCodec: audioStream.codec_name || "unknown",
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
};
})();
ffprobe.on("close", (code) => {
if (code !== 0) {
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
return;
}
try {
const output: FFProbeOutput = JSON.parse(stdout);
const audioStream = output.streams.find((s) => s.codec_type === "audio");
if (!audioStream) {
reject(new Error("[FFmpeg] No audio stream found"));
return;
}
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
const metadata: AudioMetadata = {
durationSeconds,
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
channels: audioStream.channels || 2,
audioCodec: audioStream.codec_name || "unknown",
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
};
resolve(metadata);
} catch (parseError: unknown) {
reject(
new Error(
`[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`,
),
);
}
});
ffprobe.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
} else {
reject(err);
}
});
});
audioMetadataCache.set(filePath, probePromise);
probePromise.catch(() => {
if (audioMetadataCache.get(filePath) === probePromise) {
@@ -211,3 +174,77 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
});
return probePromise;
}
export interface KeyframeAnalysis {
avgIntervalSeconds: number;
maxIntervalSeconds: number;
keyframeCount: number;
isProblematic: boolean;
}
const keyframeCache = new Map<string, Promise<KeyframeAnalysis>>();
/**
* Check keyframe intervals in a video file. Intervals > 2s cause seeking
* issues in the headless renderer and audio/video desync. Videos from
* yt-dlp --download-sections or screen recordings often have sparse keyframes.
*/
export async function analyzeKeyframeIntervals(filePath: string): Promise<KeyframeAnalysis> {
const cached = keyframeCache.get(filePath);
if (cached) return cached;
const promise = analyzeKeyframeIntervalsUncached(filePath);
keyframeCache.set(filePath, promise);
promise.catch(() => {
if (keyframeCache.get(filePath) === promise) {
keyframeCache.delete(filePath);
}
});
return promise;
}
async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<KeyframeAnalysis> {
const stdout = await runFfprobe([
"-v",
"quiet",
"-select_streams",
"v:0",
"-skip_frame",
"nokey",
"-show_entries",
"frame=pts_time",
"-of",
"csv=p=0",
filePath,
]);
const timestamps = stdout
.split("\n")
.map((line) => parseFloat(line.trim()))
.filter((t) => Number.isFinite(t));
if (timestamps.length < 2) {
return {
avgIntervalSeconds: 0,
maxIntervalSeconds: 0,
keyframeCount: timestamps.length,
isProblematic: false,
};
}
let maxInterval = 0;
let totalInterval = 0;
for (let i = 1; i < timestamps.length; i++) {
const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
totalInterval += interval;
if (interval > maxInterval) maxInterval = interval;
}
const avgInterval = totalInterval / (timestamps.length - 1);
return {
avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
keyframeCount: timestamps.length,
isProblematic: maxInterval > 2,
};
}
+35 -1
View File
@@ -26,6 +26,7 @@ import {
type VideoElement,
parseAudioElements,
type AudioElement,
analyzeKeyframeIntervals,
} from "@hyperframes/engine";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import type { Page } from "puppeteer-core";
@@ -683,8 +684,17 @@ export async function compileForRender(
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
// Strip preload="none" from media elements — the renderer needs to load all
// media upfront for frame capture. Users add this to reduce browser memory in
// preview, but it causes the headless renderer to never load the media, leading
// to 45s timeout failures.
const sanitizedHtml = inlinedHtml.replace(
/(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
"$1",
);
const html = injectDeterministicFontFaces(
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml)),
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
);
// Parse main HTML elements
@@ -694,6 +704,30 @@ export async function compileForRender(
const videos = dedupeElementsById([...subVideos, ...mainVideos]);
const audios = dedupeElementsById([...subAudios, ...mainAudios]);
// Advisory video checks (sparse keyframes, VFR). Fire-and-forget — these spawn
// ffprobe subprocesses and should not block compilation since they only produce warnings.
for (const video of videos) {
if (isHttpUrl(video.src)) continue;
const videoPath = resolve(projectDir, video.src);
const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)])
.then(([analysis, metadata]) => {
if (analysis.isProblematic) {
console.warn(
`[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). ` +
`This causes seek failures and frame freezing. Re-encode with: ${reencode}`,
);
}
if (metadata.isVFR) {
console.warn(
`[Compiler] WARNING: Video "${video.id}" is variable frame rate (VFR). ` +
`Screen recordings and phone videos are often VFR, which causes stuttering and frame skipping in renders. Re-encode with: ${reencode}`,
);
}
})
.catch(() => {});
}
// Read dimensions from root composition element using DOM parser
const { document } = parseHTML(html);
const rootEl = document.querySelector("[data-composition-id]");
@@ -1114,6 +1114,22 @@ export async function executeRenderJob(
}
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
// Suggest single-worker retry on parallel capture timeout.
// Video-heavy compositions often cause multi-worker timeouts because
// Chrome can't seek multiple video elements simultaneously.
const isTimeoutError =
errorMessage.includes("Waiting failed") ||
errorMessage.includes("timeout exceeded") ||
errorMessage.includes("Navigation timeout");
const wasParallel = job.config.workers !== 1;
if (isTimeoutError && wasParallel) {
log.warn(
`Parallel capture timed out with ${job.config.workers ?? "auto"} workers. ` +
`Video-heavy compositions often need sequential capture. Retry with --workers 1`,
);
}
job.error = errorMessage;
updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
+6395
View File
File diff suppressed because it is too large Load Diff