mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(producer): true alpha output for webm, mov, and png-sequence
Extends RenderConfig.format with "png-sequence" and patches two correctness
gaps so the existing "webm" / "mov" values actually preserve the alpha
channel end-to-end.
Engine fixes:
- screenshotService.pageScreenshotCapture: drop optimizeForSpeed for PNG
captures. The fast path uses an alpha-unaware codec that crushes real
alpha values; kept for opaque jpeg captures where it is harmless.
- frameCapture: replace the inline setDefaultBackgroundColorOverride
block (which fired pre-navigation and was reset by page.goto) with a
proper initTransparentBackground() call inside initializeSession,
after the window.__hf readiness poll. This also injects the
html/body/[data-composition-id]{background:transparent !important}
stylesheet so compositions with custom body / #root backgrounds do not
defeat the override. Wired into both screenshot-mode and beginframe-mode
branches.
Producer:
- RenderConfig.format extended to "mp4" | "webm" | "mov" | "png-sequence"
with full JSDoc.
- Streaming encode is bypassed for png-sequence (frames go straight to
disk). FORMAT_EXT extended.
- New Stage-5 png-sequence branch: mkdir outputPath, copy captured PNGs as
frame_NNNNNN.png, copy audio.aac sidecar when audio is present.
- Stage-6 mux/faststart and the debug copy are wrapped in !isPngSequence.
- README.md: new "Transparent Video Output" section.
Tests:
- New fixture tests/transparency-regression/ tagged "transparency".
- New tsx script src/transparency-test.ts asserts pixel-level alpha for
webm + png-sequence outputs. Wired as "test:transparency".
- Default "test" / "test:update" scripts pass --exclude-tags transparency
so the golden-MP4 harness ignores the new fixture.
Verified locally on macOS arm64: typecheck clean across engine + producer,
producer renderOrchestrator vitest 10/10, transparency-test passes for
both webm and png-sequence with end-to-end pixel assertions.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Transparency Regression Test
|
||||
*
|
||||
* Exercises the alpha-output pipelines (webm + png-sequence) end-to-end
|
||||
* against `tests/transparency-regression/`. Asserts that:
|
||||
*
|
||||
* 1. Pixels that were transparent in the browser stay transparent in the
|
||||
* output (alpha = 0).
|
||||
* 2. Pixels covered by the opaque red `.card` element stay fully opaque
|
||||
* (alpha = 255) and keep their red color.
|
||||
*
|
||||
* This is intentionally NOT wired into `regression-harness.ts` — the harness
|
||||
* compares each fixture against a golden MP4, but transparency requires a
|
||||
* different validation strategy (pixel inspection of the alpha channel). Run
|
||||
* this script via `bun run --filter @hyperframes/producer test:transparency`
|
||||
* or directly via `tsx src/transparency-test.ts` from this package.
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { decodePng, runFfmpeg } from "@hyperframes/engine";
|
||||
import { createRenderJob, executeRenderJob } from "./services/renderOrchestrator.js";
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_DIR = resolve(moduleDir, "../tests/transparency-regression");
|
||||
const FIXTURE_SRC = join(FIXTURE_DIR, "src");
|
||||
|
||||
const WIDTH = 200;
|
||||
const HEIGHT = 200;
|
||||
const FPS = 30;
|
||||
const TRANSPARENT_X = 10; // expected fully transparent
|
||||
const TRANSPARENT_Y = 10;
|
||||
const OPAQUE_X = 100; // inside the 50–150 red card
|
||||
const OPAQUE_Y = 100;
|
||||
|
||||
function pixelOffset(x: number, y: number, width: number): number {
|
||||
return (y * width + x) * 4;
|
||||
}
|
||||
|
||||
function assertAlphaPixel(
|
||||
png: { data: Uint8Array; width: number; height: number },
|
||||
x: number,
|
||||
y: number,
|
||||
expectAlpha: "transparent" | "opaque-red",
|
||||
label: string,
|
||||
): void {
|
||||
assert.equal(png.width, WIDTH, `${label}: width mismatch`);
|
||||
assert.equal(png.height, HEIGHT, `${label}: height mismatch`);
|
||||
const off = pixelOffset(x, y, png.width);
|
||||
const r = png.data[off + 0];
|
||||
const g = png.data[off + 1];
|
||||
const b = png.data[off + 2];
|
||||
const a = png.data[off + 3];
|
||||
if (expectAlpha === "transparent") {
|
||||
assert.equal(
|
||||
a,
|
||||
0,
|
||||
`${label}: pixel (${x},${y}) expected fully transparent (alpha=0), got rgba(${r},${g},${b},${a})`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
a,
|
||||
255,
|
||||
`${label}: pixel (${x},${y}) expected fully opaque (alpha=255), got rgba(${r},${g},${b},${a})`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof r === "number" && r >= 240,
|
||||
`${label}: pixel (${x},${y}) expected red >= 240, got rgba(${r},${g},${b},${a})`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof g === "number" && g <= 30,
|
||||
`${label}: pixel (${x},${y}) expected green <= 30, got rgba(${r},${g},${b},${a})`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof b === "number" && b <= 30,
|
||||
`${label}: pixel (${x},${y}) expected blue <= 30, got rgba(${r},${g},${b},${a})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractFirstFrameFromWebm(webmPath: string, outPng: string): Promise<void> {
|
||||
// VP9 alpha is encoded as a separate intra-frame stream inside the WebM,
|
||||
// and ffmpeg's default decoder path silently discards it. Forcing the
|
||||
// libvpx-vp9 decoder via `-c:v libvpx-vp9` BEFORE `-i` is what engages
|
||||
// the alpha-aware decode — without it the captured transparent pixels
|
||||
// come out opaque even when the file was correctly encoded as yuva420p.
|
||||
// `-update 1` permits writing a single PNG (no `%d` pattern in the path)
|
||||
// and silences the otherwise-noisy ffmpeg warning.
|
||||
const result = await runFfmpeg(
|
||||
[
|
||||
"-y",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-i",
|
||||
webmPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-update",
|
||||
"1",
|
||||
outPng,
|
||||
],
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`ffmpeg failed extracting frame 0 from ${webmPath}: ${result.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebmCheck(workRoot: string): Promise<void> {
|
||||
console.log("\n[webm] rendering transparency-regression …");
|
||||
const outDir = join(workRoot, "webm");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outPath = join(outDir, "out.webm");
|
||||
|
||||
const job = createRenderJob({
|
||||
fps: FPS,
|
||||
quality: "draft",
|
||||
format: "webm",
|
||||
});
|
||||
|
||||
await executeRenderJob(job, FIXTURE_SRC, outPath);
|
||||
assert.equal(job.status, "complete", `webm render did not complete: status=${job.status}`);
|
||||
assert.ok(existsSync(outPath), `webm output not written to ${outPath}`);
|
||||
const size = (await import("node:fs")).statSync(outPath).size;
|
||||
assert.ok(size > 0, `webm output ${outPath} is empty`);
|
||||
console.log(`[webm] rendered ${outPath} (${size} bytes)`);
|
||||
|
||||
const framePng = join(outDir, "frame-0.png");
|
||||
await extractFirstFrameFromWebm(outPath, framePng);
|
||||
const decoded = decodePng(readFileSync(framePng));
|
||||
assertAlphaPixel(decoded, TRANSPARENT_X, TRANSPARENT_Y, "transparent", "webm");
|
||||
assertAlphaPixel(decoded, OPAQUE_X, OPAQUE_Y, "opaque-red", "webm");
|
||||
console.log("[webm] PASS — transparent + opaque-red pixels verified");
|
||||
}
|
||||
|
||||
async function runPngSequenceCheck(workRoot: string): Promise<void> {
|
||||
console.log("\n[png-sequence] rendering transparency-regression …");
|
||||
const outDir = join(workRoot, "pngs");
|
||||
// executeRenderJob mkdirs outputPath itself; deliberately leave it absent.
|
||||
|
||||
const job = createRenderJob({
|
||||
fps: FPS,
|
||||
quality: "draft",
|
||||
format: "png-sequence",
|
||||
});
|
||||
|
||||
await executeRenderJob(job, FIXTURE_SRC, outDir);
|
||||
assert.equal(
|
||||
job.status,
|
||||
"complete",
|
||||
`png-sequence render did not complete: status=${job.status}`,
|
||||
);
|
||||
assert.ok(existsSync(outDir), `png-sequence output dir missing: ${outDir}`);
|
||||
|
||||
const frames = readdirSync(outDir)
|
||||
.filter((name) => name.startsWith("frame_") && name.endsWith(".png"))
|
||||
.sort();
|
||||
assert.equal(
|
||||
frames.length,
|
||||
FPS, // 1 second at 30fps = 30 frames
|
||||
`png-sequence expected ${FPS} frames, got ${frames.length}: ${frames.join(",")}`,
|
||||
);
|
||||
assert.equal(frames[0], "frame_000001.png", "first frame should be frame_000001.png");
|
||||
assert.equal(
|
||||
frames[frames.length - 1],
|
||||
`frame_${String(FPS).padStart(6, "0")}.png`,
|
||||
`last frame should be frame_${String(FPS).padStart(6, "0")}.png`,
|
||||
);
|
||||
console.log(`[png-sequence] wrote ${frames.length} frames to ${outDir}`);
|
||||
|
||||
const firstFrame = frames[0];
|
||||
if (!firstFrame) throw new Error("png-sequence: first frame missing");
|
||||
const decoded = decodePng(readFileSync(join(outDir, firstFrame)));
|
||||
assertAlphaPixel(decoded, TRANSPARENT_X, TRANSPARENT_Y, "transparent", "png-sequence");
|
||||
assertAlphaPixel(decoded, OPAQUE_X, OPAQUE_Y, "opaque-red", "png-sequence");
|
||||
console.log("[png-sequence] PASS — transparent + opaque-red pixels verified");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!existsSync(FIXTURE_SRC)) {
|
||||
throw new Error(`Fixture missing: ${FIXTURE_SRC}`);
|
||||
}
|
||||
const workRoot = join(tmpdir(), `hf-transparency-${process.pid}-${Date.now()}`);
|
||||
mkdirSync(workRoot, { recursive: true });
|
||||
const keepWork = process.env.KEEP_TEMP === "1";
|
||||
console.log(`work dir: ${workRoot}${keepWork ? " (KEEP_TEMP=1)" : ""}`);
|
||||
|
||||
try {
|
||||
await runWebmCheck(workRoot);
|
||||
await runPngSequenceCheck(workRoot);
|
||||
console.log("\nAll transparency assertions passed.");
|
||||
} finally {
|
||||
if (!keepWork) {
|
||||
try {
|
||||
rmSync(workRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\nTransparency regression test FAILED:");
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user