fix: add media rendering guardrails to prevent silent failures (#112)

## Summary

- **Lint rules** catch media elements missing `id` (renderer silently skips them), missing `src`, `preload="none"` (blocks renderer), and video nested in timed divs (freezes playback). Upgraded `video_nested_in_timed_element` from warning to error.
- **Compiler** strips `preload="none"` from media during compilation. Runs parallel, cached keyframe interval analysis via ffprobe — warns on sparse keyframes (>2s) that cause seek failures and audio/video desync. Suggested ffmpeg command preserves audio (`-c:a copy`).
- **Pre-render lint** lints `index.html` + all `compositions/*.html` sub-compositions before render via shared `lintProject()` helper. Warns by default; `--strict` blocks on errors, `--strict-all` blocks on errors + warnings.
- **Render orchestrator** logs a hint to retry with `--workers 1` when parallel capture times out on video-heavy compositions.
- **Refactor**: extracted `runFfprobe()` + `parseProbeJson()` helpers to deduplicate ~80 lines of spawn boilerplate across 3 ffprobe functions. Extracted `shouldBlockRender()` so strict flag tests exercise production code. Shared `lintProject()` used by both `lint` and `render` commands.

## Context

Discovered during a real composition build session where:
1. `<audio>` without `id` rendered silently (preview worked fine because runtime queries `[data-start]`, but renderer queries `[id][src]`)
2. `<video>` inside timed `<div>` froze on first frame
3. `preload="none"` caused 45s renderer timeout
4. YouTube clips with sparse keyframes from `yt-dlp --download-sections` caused audio/video desync
5. Parallel workers timed out on video-heavy compositions

## Test plan

- [x] Core: 365/365 tests passing (5 new lint tests)
- [x] Engine: 24/24 tests passing
- [x] CLI: 14/14 tests passing (7 lintProject + 7 shouldBlockRender)
- [x] Lint + format hooks pass
- [ ] Manual: create a composition with `<audio data-start="0" src="test.wav">` (no id) — verify `npx hyperframes lint` catches it
- [ ] Manual: run `npx hyperframes render --strict` with lint errors — verify it blocks
- [ ] Manual: run `npx hyperframes render --strict-all` with lint warnings — verify it blocks
This commit is contained in:
Vance Ingalls
2026-03-30 11:07:19 -07:00
committed by GitHub
parent 808d196fe0
commit 229538c622
15 changed files with 7334 additions and 173 deletions
+333 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
import { describe, it, expect, vi } from "vitest";
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.