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
+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, {