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
+10
View File
@@ -62,6 +62,8 @@ export interface VideoMetadata {
hasAudio: boolean;
/** True when r_frame_rate and avg_frame_rate differ significantly (>10%), indicating variable frame rate. */
isVFR: boolean;
/** True when the stream carries an alpha channel. */
hasAlpha: boolean;
/** Color space info from the video stream. Null if ffprobe didn't report it. */
colorSpace: VideoColorSpace | null;
}
@@ -79,6 +81,7 @@ interface FFProbeStream {
codec_name?: string;
width?: number;
height?: number;
pix_fmt?: string;
r_frame_rate?: string;
avg_frame_rate?: string;
sample_rate?: string;
@@ -86,6 +89,7 @@ interface FFProbeStream {
color_transfer?: string;
color_primaries?: string;
color_space?: string;
tags?: Record<string, string>;
}
interface FFProbeFormat {
@@ -251,6 +255,7 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
videoCodec: "png",
hasAudio: false,
isVFR: false,
hasAlpha: false,
colorSpace: stillImageMeta.colorSpace,
};
}
@@ -271,6 +276,10 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
? { colorTransfer, colorPrimaries, colorSpace: colorSpaceVal }
: null;
const colorSpace = ffprobeColorSpace ?? stillImageMeta?.colorSpace ?? null;
const pixelFormat = videoStream.pix_fmt || "";
const alphaMode = videoStream.tags?.alpha_mode || "";
const hasAlpha =
/(^|[^a-z])yuva|rgba|argb|bgra|gbrap|gray[a-z0-9]*a/i.test(pixelFormat) || alphaMode === "1";
return {
durationSeconds: output?.format.duration ? parseFloat(output.format.duration) : 0,
@@ -280,6 +289,7 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
videoCodec: videoStream.codec_name || "unknown",
hasAudio: output?.streams.some((s) => s.codec_type === "audio") ?? false,
isVFR,
hasAlpha,
colorSpace,
};
})();