mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
## 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.
154 lines
5.6 KiB
TypeScript
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"');
|
|
});
|
|
});
|