Files
hyperframes/packages/producer/src/transparency-test.ts
T
Miguel Ángel 6cfb05e38b fix(render): preserve transparency in GIF output (#2327)
## What

- Treat GIF as an alpha-capable output format and capture its frames as RGBA PNGs.
- Encode transparent GIFs with explicit FFmpeg palette semantics: `reserve_transparent=1` and `alpha_threshold=128`.
- Keep page-side shader compositing enabled for GIF while the resulting composite is captured through the RGBA disk-frame path.
- Extend the real render harness to verify decoded GIF alpha and compare a GIF shader-transition frame against the existing MP4 golden.
- Preserve the existing opaque encoder contract: `needsAlpha=false` continues to use JPEG frames without alpha-only palette filters.

## Why

Direct `--format gif` renders silently flattened transparent compositions when frames are captured as JPEG, because the palette encoder receives no alpha plane to preserve.

GIF also needs page-side shader compositing. A blanket `needsAlpha` exclusion disabled that path after enabling RGBA capture, while the layered compositor intentionally excludes GIF. That left shader GIFs on the DOM fallback and produced hard cuts instead of the authored WebGL blend.

## How

- Centralize output alpha detection in `outputNeedsAlpha`, shared by in-process and distributed planning.
- Select PNG or JPEG GIF frame input from the resolved alpha requirement.
- Make palette transparency flags explicit and conditional so the legacy opaque path retains its existing arguments.
- Add an explicit output-format capability for page-side shader compositing: MP4 keeps its opaque streaming path, GIF uses RGBA PNG disk frames, and WebM/MOV/PNG sequence retain their existing paths.
- Add `data-no-timeline` to the static transparency fixture so the artifact regression does not wait for a timeline it intentionally does not register.

## Test plan

- [x] RED on base: direct GIF decoded with an opaque corner instead of alpha 0.
- [x] RED on the previous PR head: the real GIF shader-transition frame scored 11.05 dB against the existing golden because neither shader compositor was active.
- [x] `bun test packages/producer/src/services/render/renderFormat.test.ts packages/producer/src/services/render/stages/encodeStage.test.ts packages/producer/src/services/render/capturePlan.test.ts` — 23 passed.
- [x] `bun run --filter @hyperframes/producer typecheck`
- [x] `bun run --filter @hyperframes/producer build`
- [x] `bun run --filter @hyperframes/producer test:transparency` — WebM, GIF, and PNG sequence alpha assertions passed; GIF shader control/transition frames scored 28.11/26.57 dB against the golden.
- [x] `bun run --cwd packages/producer tsx src/regression-harness.ts page-side-shader-compositor-render-compat --sequential` — all 100 visual checkpoints passed, stream parity passed, and audio correlation was 1.000.
- [x] Changed-file oxlint, oxfmt check, pre-commit checks, and `git diff --check`.
2026-07-29 20:05:42 +02:00

339 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Transparency Regression Test
*
* Exercises the alpha-output pipelines (webm + gif + png-sequence) end-to-end
* against `tests/transparency-regression/`, then renders the real page-side
* shader fixture to GIF. 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.
* 3. GIF's RGBA disk-frame path still captures the authored WebGL shader
* transition instead of the virtual-time DOM fallback.
*
* 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, psnrDb, 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 SHADER_FIXTURE_DIR = resolve(moduleDir, "../tests/page-side-shader-compositor-render-compat");
const SHADER_FIXTURE_SRC = join(SHADER_FIXTURE_DIR, "src");
const SHADER_GOLDEN = join(SHADER_FIXTURE_DIR, "output", "output.mp4");
const WIDTH = 200;
const HEIGHT = 200;
const FPS: import("@hyperframes/core").Fps = { num: 30, den: 1 };
const PNG_SEQUENCE_FRAME_COUNT = FPS.num / FPS.den;
const TRANSPARENT_X = 10; // expected fully transparent
const TRANSPARENT_Y = 10;
const OPAQUE_X = 100; // inside the 50150 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 extractFirstFrameFromGif(gifPath: string, outPng: string): Promise<void> {
const result = await runFfmpeg(
["-y", "-i", gifPath, "-frames:v", "1", "-pix_fmt", "rgba", "-update", "1", outPng],
{ timeout: 60_000 },
);
if (!result.success) {
throw new Error(
`ffmpeg failed extracting frame 0 from ${gifPath}: ${result.stderr.slice(-400)}`,
);
}
}
async function extractFrameAtIndex(
inputPath: string,
frameIndex: number,
outPng: string,
): Promise<void> {
const result = await runFfmpeg(
[
"-y",
"-i",
inputPath,
"-vf",
`select=eq(n\\,${frameIndex})`,
"-frames:v",
"1",
"-update",
"1",
outPng,
],
{ timeout: 60_000 },
);
if (!result.success) {
throw new Error(
`ffmpeg failed extracting frame ${frameIndex} from ${inputPath}: ${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 runGifCheck(workRoot: string): Promise<void> {
console.log("\n[gif] rendering transparency-regression …");
const outDir = join(workRoot, "gif");
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, "out.gif");
const job = createRenderJob({
fps: { num: 15, den: 1 },
quality: "draft",
format: "gif",
gifLoop: 0,
});
await executeRenderJob(job, FIXTURE_SRC, outPath);
assert.equal(job.status, "complete", `gif render did not complete: status=${job.status}`);
assert.ok(existsSync(outPath), `gif output not written to ${outPath}`);
const size = (await import("node:fs")).statSync(outPath).size;
assert.ok(size > 0, `gif output ${outPath} is empty`);
console.log(`[gif] rendered ${outPath} (${size} bytes)`);
const framePng = join(outDir, "frame-0.png");
await extractFirstFrameFromGif(outPath, framePng);
const decoded = decodePng(readFileSync(framePng));
assertAlphaPixel(decoded, TRANSPARENT_X, TRANSPARENT_Y, "transparent", "gif");
assertAlphaPixel(decoded, OPAQUE_X, OPAQUE_Y, "opaque-red", "gif");
console.log("[gif] PASS — transparent + opaque-red pixels verified");
}
async function runGifShaderTransitionCheck(workRoot: string): Promise<void> {
console.log("\n[gif-shader] rendering page-side shader transition …");
const outDir = join(workRoot, "gif-shader");
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, "out.gif");
const job = createRenderJob({
fps: { num: 15, den: 1 },
quality: "draft",
format: "gif",
gifLoop: 0,
workers: 1,
});
await executeRenderJob(job, SHADER_FIXTURE_SRC, outPath);
assert.equal(job.status, "complete", `gif shader render did not complete: status=${job.status}`);
assert.ok(existsSync(outPath), `gif shader output not written to ${outPath}`);
const gifBefore = join(outDir, "gif-before.png");
const gifTransition = join(outDir, "gif-transition.png");
const goldenBefore = join(outDir, "golden-before.png");
const goldenTransition = join(outDir, "golden-transition.png");
await Promise.all([
extractFrameAtIndex(outPath, 7, gifBefore),
extractFrameAtIndex(outPath, 17, gifTransition),
extractFrameAtIndex(SHADER_GOLDEN, 14, goldenBefore),
extractFrameAtIndex(SHADER_GOLDEN, 34, goldenTransition),
]);
const beforePsnr = await psnrDb(readFileSync(gifBefore), readFileSync(goldenBefore));
const transitionPsnr = await psnrDb(readFileSync(gifTransition), readFileSync(goldenTransition));
assert.ok(
beforePsnr >= 25,
`gif shader control frame expected >=25 dB against the golden, got ${beforePsnr.toFixed(2)} dB`,
);
assert.ok(
transitionPsnr >= 20,
`gif shader transition expected >=20 dB against the golden, got ${transitionPsnr.toFixed(2)} dB`,
);
console.log(
`[gif-shader] PASS — control ${beforePsnr.toFixed(2)} dB, transition ${transitionPsnr.toFixed(2)} dB`,
);
}
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,
PNG_SEQUENCE_FRAME_COUNT, // 1 second at 30fps = 30 frames
`png-sequence expected ${PNG_SEQUENCE_FRAME_COUNT} 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(PNG_SEQUENCE_FRAME_COUNT).padStart(6, "0")}.png`,
`last frame should be frame_${String(PNG_SEQUENCE_FRAME_COUNT).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}`);
}
if (!existsSync(SHADER_FIXTURE_SRC) || !existsSync(SHADER_GOLDEN)) {
throw new Error(`Shader fixture or golden missing: ${SHADER_FIXTURE_DIR}`);
}
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 runGifCheck(workRoot);
await runGifShaderTransitionCheck(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;
});