Files
hyperframes/packages/core/src/compiler/timingCompiler.test.ts
T
Miguel Ángel 31e8144304 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.
2026-04-24 23:18:20 +02:00

154 lines
5.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
} from "./timingCompiler.js";
describe("compileTimingAttrs", () => {
it("adds data-end when data-start and data-duration are present on a video", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="7"');
expect(compiled).toContain('data-has-audio="true"');
expect(unresolved).toHaveLength(0);
});
it("leaves data-end unchanged when already present", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-end="3">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="3"');
expect(compiled).not.toContain("data-duration");
expect(unresolved).toHaveLength(0);
});
it("marks video as unresolved when data-duration and data-end are missing", () => {
const html = '<video id="v1" src="a.mp4" data-start="1">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("v1");
expect(unresolved[0].tagName).toBe("video");
expect(unresolved[0].start).toBe(1);
});
it("auto-assigns ids to id-less videos so unresolved duration resolution can target them", () => {
const html = '<video src="a.mp4" data-start="1">';
const { html: compiled, unresolved } = compileTimingAttrs(html);
expect(compiled).toContain('id="hf-video-0"');
expect(compiled).toContain('data-has-audio="true"');
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("hf-video-0");
expect(unresolved[0].tagName).toBe("video");
expect(unresolved[0].start).toBe(1);
});
it("compiles audio tags the same as video (minus data-has-audio)", () => {
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
const { html: compiled } = compileTimingAttrs(html);
expect(compiled).toContain('data-end="10"');
expect(compiled).not.toContain("data-has-audio");
});
it("detects unresolved div/section elements with data-start but no data-end", () => {
const html = '<div id="comp1" data-start="0" data-composition-src="comp.html">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(1);
expect(unresolved[0].id).toBe("comp1");
expect(unresolved[0].tagName).toBe("div");
expect(unresolved[0].compositionSrc).toBe("comp.html");
});
it("does not report div as unresolved when data-end is present", () => {
const html = '<div id="comp1" data-start="0" data-end="5">';
const { unresolved } = compileTimingAttrs(html);
expect(unresolved).toHaveLength(0);
});
});
describe("injectDurations", () => {
it("adds data-duration and data-end for resolved elements", () => {
const html = '<video id="v1" src="a.mp4" data-start="2">';
const result = injectDurations(html, [{ id: "v1", duration: 4 }]);
expect(result).toContain('data-duration="4"');
expect(result).toContain('data-end="6"');
});
it("injects durations for auto-assigned media ids", () => {
const { html, unresolved } = compileTimingAttrs('<video src="a.mp4" data-start="1">');
const result = injectDurations(html, [{ id: unresolved[0]!.id, duration: 4 }]);
expect(result).toContain('id="hf-video-0"');
expect(result).toContain('data-duration="4"');
expect(result).toContain('data-end="5"');
});
it("does not overwrite existing data-duration", () => {
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3">';
const result = injectDurations(html, [{ id: "v1", duration: 10 }]);
// data-duration already present, should not be duplicated
expect(result).toContain('data-duration="3"');
});
});
describe("extractResolvedMedia", () => {
it("extracts video and audio elements with data-duration set", () => {
const html = [
'<video id="v1" src="vid.mp4" data-start="1" data-duration="5" data-media-start="0">',
'<audio id="a1" src="song.mp3" data-start="0" data-duration="10">',
'<video id="v2" src="other.mp4" data-start="0">', // no duration
].join("\n");
const resolved = extractResolvedMedia(html);
expect(resolved).toHaveLength(2);
expect(resolved[0].id).toBe("v1");
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);
expect(resolved).toHaveLength(0);
});
});
describe("clampDurations", () => {
it("replaces data-duration and recomputes data-end", () => {
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="10" data-end="12">';
const result = clampDurations(html, [{ id: "v1", duration: 5 }]);
expect(result).toContain('data-duration="5"');
expect(result).toContain('data-end="7"');
});
});