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
+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;
}