fix: render parity for transparent looped videos (#478)

## Summary
- preserve alpha for render-injected video frames by detecting alpha streams with ffprobe and extracting alpha video frames as PNG
- keep `<video loop>` semantics through static parsing, compiler duration resolution, browser media discovery, and render frame lookup
- fail embedded preview startup before opening a broken browser page when the Studio bundle is missing
- align snapshot frame injection with looped media timing and VP9 alpha extraction

## Why
The Studio preview and rendered MP4 could disagree for timed transparent looped videos. The Comfy funding composition exposed two separate parity bugs: render-injected frames needed alpha-preserving PNG extraction, and the compiler was clamping a looped `data-duration="4"` video down to the 3.125s source duration. After the first source cycle, render lookup treated the video as inactive, hid the native video, and produced the blank polygon/glow the user saw around the rounded `0:03` mark.

`hyperframes lint` and `hyperframes validate` did not catch this because they check syntax/load/console/accessibility, not preview-vs-render visual parity. This PR adds regression coverage for the compiler loop-duration path and frame lookup path.

## Verification
- `bun run --filter @hyperframes/core test -- src/compiler/timingCompiler.test.ts src/compiler/htmlCompiler.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bun run --filter @hyperframes/engine test -- videoFrameExtractor ffprobe`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run lint`
- `bun run format:check ...` on touched files
- Comfy project: `node packages/cli/dist/cli.js validate` -> no console errors, 44 text elements pass WCAG AA
- Comfy project patched render from source: `/tmp/comfy-render-compare/fixed6-comfy.mp4`, 1920x1080, 30fps, 21.8s, 654 frames
- 3.00s-3.97s render contact sheet: `/tmp/comfy-render-compare/fixed6-window-contact.png`
- targeted fixed render capture at 3.733s: `/tmp/comfy-render-compare/probe-capture-fixed/captured/frame_000112.jpg`
- agent-browser Studio proof screenshot at 3.7s: `/tmp/comfy-render-compare/agent-browser-studio-3_7-fixed.png`
- agent-browser-driven recording of 3s seek pass: `/tmp/comfy-render-compare/agent-browser-wysiwyg-3s-fixed.webm`

Note: `bun run --filter @hyperframes/cli dev -- validate` is blocked in source mode by the existing `contrast-audit.browser.js` default-export loader issue; packaged `node packages/cli/dist/cli.js validate` passes for this project.
This commit is contained in:
Miguel Ángel
2026-04-24 23:18:20 +02:00
committed by GitHub
parent e8c43f0889
commit 31e8144304
13 changed files with 384 additions and 27 deletions
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { compileHtml } from "./htmlCompiler.js";
describe("compileHtml", () => {
it("preserves explicit looped media durations that exceed source duration", async () => {
const html =
'<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4" loop>';
const compiled = await compileHtml(html, "/project", async () => 3.125);
expect(compiled).toContain('data-duration="4"');
expect(compiled).toContain('data-end="4"');
});
it("still clamps non-looping media durations to source duration", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4">';
const compiled = await compileHtml(html, "/project", async () => 3.125);
expect(compiled).toContain('data-duration="3.125"');
expect(compiled).toContain('data-end="3.125"');
});
});
@@ -70,6 +70,7 @@ export async function compileHtml(
for (const el of preResolved) {
if (!el.src) continue;
if (el.loop) continue;
const src = resolveMediaSrc(el.src, projectDir);
const fileDuration = await probeMediaDuration(src);
if (fileDuration <= 0) continue;
@@ -115,11 +115,26 @@ describe("extractResolvedMedia", () => {
expect(resolved[0].tagName).toBe("video");
expect(resolved[0].duration).toBe(5);
expect(resolved[0].start).toBe(1);
expect(resolved[0].loop).toBe(false);
expect(resolved[1].id).toBe("a1");
expect(resolved[1].tagName).toBe("audio");
expect(resolved[1].duration).toBe(10);
});
it("marks looped media so render compilation can preserve display duration", () => {
const html = '<video id="v1" src="vid.webm" data-start="0" data-duration="4" loop>';
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(1);
expect(resolved[0]).toMatchObject({
id: "v1",
tagName: "video",
duration: 4,
loop: true,
});
});
it("skips elements with invalid durations", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="NaN">';
const resolved = extractResolvedMedia(html);
+3 -1
View File
@@ -40,6 +40,7 @@ export interface ResolvedMediaElement {
start: number;
duration: number;
mediaStart: number;
loop: boolean;
}
export interface CompilationResult {
@@ -55,7 +56,7 @@ function getAttr(tag: string, attr: string): string | null {
}
function hasAttr(tag: string, attr: string): boolean {
return new RegExp(`${attr}=["']`).test(tag);
return new RegExp(`\\s${attr}(?:\\s|=|>|/)`).test(tag);
}
function injectAttr(tag: string, attr: string, value: string): string {
@@ -229,6 +230,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
start: startStr !== null ? parseFloat(startStr) : 0,
duration,
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
loop: hasAttr(tag, "loop"),
});
}