mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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`.
This commit is contained in:
@@ -66,6 +66,7 @@ import {
|
||||
validateNoSystemFonts,
|
||||
} from "../render/planValidation.js";
|
||||
import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
|
||||
import { outputNeedsAlpha } from "../render/renderFormat.js";
|
||||
import {
|
||||
buildSyntheticRenderJob,
|
||||
buildPlanVideosJson,
|
||||
@@ -888,7 +889,7 @@ export async function plan(
|
||||
// move the contents over once the staged work completes.
|
||||
const finalCompiledDir = join(planDir, "compiled");
|
||||
|
||||
// webm + mov + png-sequence carry alpha — flip force-screenshot so
|
||||
// Alpha-capable distributed formats flip force-screenshot so
|
||||
// compileStage takes the alpha-aware capture path (BeginFrame doesn't
|
||||
// preserve alpha on Linux headless-shell). Must match the in-process
|
||||
// renderer's needsAlpha logic in `renderOrchestrator.ts` so chunked
|
||||
@@ -897,8 +898,7 @@ export async function plan(
|
||||
// into the planDir and every chunk worker captures opaque RGB — the
|
||||
// libvpx-vp9 alpha sub-stream then encodes either uniform alpha or
|
||||
// gets downgraded by the encoder, producing un-keyable webm output.
|
||||
const needsAlpha =
|
||||
config.format === "png-sequence" || config.format === "mov" || config.format === "webm";
|
||||
const needsAlpha = outputNeedsAlpha(config.format);
|
||||
|
||||
// ── Compile ──
|
||||
const compileResult = await runCompileStage({
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { outputNeedsAlpha, outputSupportsPageSideShaderCompositing } from "./renderFormat.js";
|
||||
|
||||
describe("outputNeedsAlpha", () => {
|
||||
it("uses alpha-aware capture for transparent-capable formats", () => {
|
||||
expect(outputNeedsAlpha("gif")).toBe(true);
|
||||
expect(outputNeedsAlpha("webm")).toBe(true);
|
||||
expect(outputNeedsAlpha("mov")).toBe(true);
|
||||
expect(outputNeedsAlpha("png-sequence")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves opaque capture for mp4", () => {
|
||||
expect(outputNeedsAlpha("mp4")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outputSupportsPageSideShaderCompositing", () => {
|
||||
it("supports opaque MP4 capture and RGBA GIF disk frames", () => {
|
||||
expect(outputSupportsPageSideShaderCompositing("mp4")).toBe(true);
|
||||
expect(outputSupportsPageSideShaderCompositing("gif")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the alpha video and PNG sequence formats on their existing paths", () => {
|
||||
expect(outputSupportsPageSideShaderCompositing("webm")).toBe(false);
|
||||
expect(outputSupportsPageSideShaderCompositing("mov")).toBe(false);
|
||||
expect(outputSupportsPageSideShaderCompositing("png-sequence")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export type RenderOutputFormat = "mp4" | "webm" | "mov" | "png-sequence" | "gif";
|
||||
|
||||
export function outputNeedsAlpha(format: RenderOutputFormat): boolean {
|
||||
return format !== "mp4";
|
||||
}
|
||||
|
||||
export function outputSupportsPageSideShaderCompositing(format: RenderOutputFormat): boolean {
|
||||
return format === "mp4" || format === "gif";
|
||||
}
|
||||
@@ -120,6 +120,7 @@ describe("gif encode args", () => {
|
||||
outputPath: "/tmp/hf/demo.gif",
|
||||
fps: { num: 15, den: 1 },
|
||||
loop: 0,
|
||||
preserveAlpha: false,
|
||||
};
|
||||
|
||||
it("builds the palettegen pass with diff statistics", () => {
|
||||
@@ -151,6 +152,21 @@ describe("gif encode args", () => {
|
||||
"/tmp/hf/demo.gif",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reserves transparency and applies the GIF alpha threshold for RGBA frames", () => {
|
||||
const transparentInput = {
|
||||
...input,
|
||||
framePattern: "frame_%06d.png",
|
||||
preserveAlpha: true,
|
||||
};
|
||||
|
||||
expect(buildGifPalettegenArgs(transparentInput)).toContain(
|
||||
"fps=15,palettegen=stats_mode=diff:reserve_transparent=1",
|
||||
);
|
||||
expect(buildGifPaletteuseArgs(transparentInput)).toContain(
|
||||
"fps=15 [x]; [x][1:v] paletteuse=dither=sierra2_4a:alpha_threshold=128",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEncodeStage config plumbing", () => {
|
||||
@@ -221,4 +237,48 @@ describe("runEncodeStage config plumbing", () => {
|
||||
resolvedEngineConfig.ffmpegEncodeTimeout,
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes alpha GIFs from PNG frames with explicit transparency filters", async () => {
|
||||
const { runEncodeStage } = await import("./encodeStage.js");
|
||||
const paths = createFramesDir("png");
|
||||
|
||||
await runEncodeStage(
|
||||
makeInput({
|
||||
framesDir: paths.framesDir,
|
||||
outputPath: join(paths.root, "out.gif"),
|
||||
videoOnlyPath: join(paths.root, "video-only.mp4"),
|
||||
isGif: true,
|
||||
needsAlpha: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runFfmpegMock).toHaveBeenCalledTimes(2);
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(paths.framesDir, "frame_%06d.png"));
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(
|
||||
"fps=30,palettegen=stats_mode=diff:reserve_transparent=1",
|
||||
);
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).toContain(join(paths.framesDir, "frame_%06d.png"));
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).toContain(
|
||||
"fps=30 [x]; [x][1:v] paletteuse=dither=sierra2_4a:alpha_threshold=128",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps opaque GIF encoding on JPEG frames without alpha-only filters", async () => {
|
||||
const { runEncodeStage } = await import("./encodeStage.js");
|
||||
const paths = createFramesDir("jpg");
|
||||
|
||||
await runEncodeStage(
|
||||
makeInput({
|
||||
framesDir: paths.framesDir,
|
||||
outputPath: join(paths.root, "out.gif"),
|
||||
videoOnlyPath: join(paths.root, "video-only.mp4"),
|
||||
isGif: true,
|
||||
needsAlpha: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(paths.framesDir, "frame_%06d.jpg"));
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).not.toContain("reserve_transparent");
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).not.toContain("alpha_threshold");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,7 @@ async function encodeGifFromDir(
|
||||
fps: Fps;
|
||||
loop: number;
|
||||
palettePath: string;
|
||||
preserveAlpha: boolean;
|
||||
signal?: AbortSignal;
|
||||
timeout: number;
|
||||
},
|
||||
@@ -148,6 +149,7 @@ async function encodeGifFromDir(
|
||||
outputPath,
|
||||
fps: input.fps,
|
||||
loop: input.loop,
|
||||
preserveAlpha: input.preserveAlpha,
|
||||
};
|
||||
try {
|
||||
const paletteResult = await runFfmpeg(buildGifPalettegenArgs(argsInput), {
|
||||
@@ -258,12 +260,14 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
|
||||
if (hasAudio) {
|
||||
log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
|
||||
}
|
||||
const framePattern = "frame_%06d.jpg";
|
||||
const frameExt = needsAlpha ? "png" : "jpg";
|
||||
const framePattern = `frame_%06d.${frameExt}`;
|
||||
const loop = resolveGifLoop(job.config.gifLoop);
|
||||
const encodeResult = await encodeGifFromDir(framesDir, framePattern, outputPath, {
|
||||
fps: job.config.fps,
|
||||
loop,
|
||||
palettePath: join(dirname(videoOnlyPath), "gif-palette.png"),
|
||||
preserveAlpha: needsAlpha,
|
||||
signal: abortSignal,
|
||||
timeout: engineCfg.ffmpegEncodeTimeout,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface GifEncodeArgsInput {
|
||||
outputPath: string;
|
||||
fps: Fps;
|
||||
loop: number;
|
||||
preserveAlpha: boolean;
|
||||
}
|
||||
|
||||
function fpsToFfmpegArg(fps: Fps): string {
|
||||
@@ -16,6 +17,7 @@ function fpsToFfmpegArg(fps: Fps): string {
|
||||
|
||||
export function buildGifPalettegenArgs(input: GifEncodeArgsInput): string[] {
|
||||
const fpsArg = fpsToFfmpegArg(input.fps);
|
||||
const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
|
||||
return [
|
||||
"-y",
|
||||
"-framerate",
|
||||
@@ -23,13 +25,14 @@ export function buildGifPalettegenArgs(input: GifEncodeArgsInput): string[] {
|
||||
"-i",
|
||||
join(input.framesDir, input.framePattern),
|
||||
"-vf",
|
||||
`fps=${fpsArg},palettegen=stats_mode=diff`,
|
||||
`fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
|
||||
input.palettePath,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildGifPaletteuseArgs(input: GifEncodeArgsInput): string[] {
|
||||
const fpsArg = fpsToFfmpegArg(input.fps);
|
||||
const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
|
||||
return [
|
||||
"-y",
|
||||
"-framerate",
|
||||
@@ -39,7 +42,7 @@ export function buildGifPaletteuseArgs(input: GifEncodeArgsInput): string[] {
|
||||
"-i",
|
||||
input.palettePath,
|
||||
"-lavfi",
|
||||
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a`,
|
||||
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
|
||||
"-loop",
|
||||
String(input.loop),
|
||||
input.outputPath,
|
||||
|
||||
@@ -101,6 +101,11 @@ import {
|
||||
VIRTUAL_TIME_SHIM,
|
||||
} from "./fileServer.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
import {
|
||||
outputNeedsAlpha,
|
||||
outputSupportsPageSideShaderCompositing,
|
||||
type RenderOutputFormat,
|
||||
} from "./render/renderFormat.js";
|
||||
import { createMemorySampler, type MemorySampler, updateJobStatus } from "./render/shared.js";
|
||||
import { buildRenderErrorDetails } from "./render/cleanup.js";
|
||||
import { publishRenderFailure } from "./render/renderEventPublisher.js";
|
||||
@@ -284,10 +289,10 @@ export interface RenderConfig {
|
||||
* - `"mov"`: ProRes 4444 + `yuva444p10le` → **true alpha channel +
|
||||
* 10-bit color**. Sized for editor ingest (Premiere, Final Cut Pro,
|
||||
* DaVinci Resolve), not direct web playback. Audio is muxed as AAC.
|
||||
* - `"gif"`: animated GIF encoded from captured frames with a two-pass
|
||||
* - `"gif"`: animated GIF encoded from captured RGBA frames with a two-pass
|
||||
* FFmpeg palette (`palettegen` + `paletteuse`). Use for PRs, READMEs,
|
||||
* and docs where inline autoplay matters more than file size. No audio
|
||||
* stream and no alpha channel.
|
||||
* stream; transparency is binary because GIF has no partial alpha.
|
||||
* - `"png-sequence"`: a directory of zero-padded RGBA PNGs
|
||||
* (`frame_000001.png` …). Lossless alpha, largest on disk, no muxed
|
||||
* audio (an `audio.aac` sidecar is written alongside the PNGs when
|
||||
@@ -296,7 +301,7 @@ export interface RenderConfig {
|
||||
* encoding. `outputPath` is treated as a directory; it is created if
|
||||
* it doesn't exist.
|
||||
*
|
||||
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`) automatically
|
||||
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`, `"gif"`) automatically
|
||||
* forces screenshot capture (Chrome's BeginFrame compositor does not
|
||||
* preserve alpha on Linux headless-shell) and disables HDR — HDR +
|
||||
* alpha is not a supported combination, a warning is logged and HDR
|
||||
@@ -305,7 +310,7 @@ export interface RenderConfig {
|
||||
* not paint a fullscreen `body` / `#root` background in their
|
||||
* compositions when targeting alpha output.
|
||||
*/
|
||||
format?: "mp4" | "webm" | "mov" | "png-sequence" | "gif";
|
||||
format?: RenderOutputFormat;
|
||||
/** GIF Netscape loop count. 0 means infinite looping. Only used with `format: "gif"`. */
|
||||
gifLoop?: number;
|
||||
workers?: number;
|
||||
@@ -1745,8 +1750,6 @@ async function executeRenderPipeline(input: {
|
||||
renderJobId: job.id,
|
||||
});
|
||||
const outputFormat = job.config.format ?? ("mp4" as const);
|
||||
const isWebm = outputFormat === "webm";
|
||||
const isMov = outputFormat === "mov";
|
||||
const isPngSequence = outputFormat === "png-sequence";
|
||||
const isGif = outputFormat === "gif";
|
||||
const artifactTransaction = new ArtifactTransaction(
|
||||
@@ -1754,7 +1757,7 @@ async function executeRenderPipeline(input: {
|
||||
isPngSequence ? "directory" : "file",
|
||||
);
|
||||
const stagedOutputPath = artifactTransaction.stagingPath;
|
||||
const needsAlpha = isWebm || isMov || isPngSequence;
|
||||
const needsAlpha = outputNeedsAlpha(outputFormat);
|
||||
// `forceScreenshot` is resolved exactly once inside `compileStage` (alpha
|
||||
// output + composition `renderModeHints` are folded together there) and
|
||||
// returned on `compileResult.forceScreenshot`. The sequencer stores it
|
||||
@@ -2812,19 +2815,16 @@ async function executeRenderPipeline(input: {
|
||||
// Page-side compositing opt-in: when the engine is configured to run the
|
||||
// shader blend inside Chrome via a page-side WebGL canvas, the layered
|
||||
// Node-side composite path is unnecessary for SDR shader transitions.
|
||||
// The streaming path takes ONE opaque RGB screenshot per output frame —
|
||||
// exactly the single capture the page-side compositor produces. HDR
|
||||
// content still forces the layered path (HDR layers need per-layer
|
||||
// alpha + native HDR raw frame compositing in Node; that's out of scope
|
||||
// for this opt-in). GIF also uses this path for shader transitions
|
||||
// because its two-pass palette encoder needs disk frames, not the
|
||||
// layered path's streaming raw-video encoder.
|
||||
// MP4's streaming path takes one opaque RGB screenshot per output frame.
|
||||
// GIF takes the same page-side composite through its RGBA PNG disk-frame
|
||||
// path so the palette encoder can preserve transparency. HDR content still
|
||||
// forces the layered path (HDR layers need per-layer alpha + native HDR raw
|
||||
// frame compositing in Node; that's out of scope for this opt-in).
|
||||
const usePageSideCompositingForTransitions =
|
||||
(cfg.enablePageSideCompositing || isGif) &&
|
||||
compiled.hasShaderTransitions &&
|
||||
!hasHdrContent &&
|
||||
!isPngSequence &&
|
||||
!needsAlpha;
|
||||
outputSupportsPageSideShaderCompositing(outputFormat);
|
||||
if (usePageSideCompositingForTransitions) {
|
||||
activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
|
||||
if (
|
||||
@@ -2848,7 +2848,8 @@ async function executeRenderPipeline(input: {
|
||||
updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
|
||||
log.info(
|
||||
"[Render] Page-side compositing enabled — bypassing Node-side layered " +
|
||||
"shader-blend path. Engine will capture one opaque RGB screenshot per output frame.",
|
||||
`shader-blend path. Engine will capture one ${needsAlpha ? "RGBA PNG" : "opaque RGB"} ` +
|
||||
"screenshot per output frame.",
|
||||
);
|
||||
}
|
||||
const useLayeredComposite =
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Transparency Regression Test
|
||||
*
|
||||
* Exercises the alpha-output pipelines (webm + png-sequence) end-to-end
|
||||
* against `tests/transparency-regression/`. Asserts that:
|
||||
* 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
|
||||
@@ -22,16 +25,20 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { decodePng, runFfmpeg } from "@hyperframes/engine";
|
||||
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 50–150 red card
|
||||
@@ -114,6 +121,45 @@ async function extractFirstFrameFromWebm(webmPath: string, outPng: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -141,6 +187,78 @@ async function runWebmCheck(workRoot: string): Promise<void> {
|
||||
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");
|
||||
@@ -165,14 +283,14 @@ async function runPngSequenceCheck(workRoot: string): Promise<void> {
|
||||
.sort();
|
||||
assert.equal(
|
||||
frames.length,
|
||||
FPS, // 1 second at 30fps = 30 frames
|
||||
`png-sequence expected ${FPS} frames, got ${frames.length}: ${frames.join(",")}`,
|
||||
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(FPS).padStart(6, "0")}.png`,
|
||||
`last frame should be frame_${String(FPS).padStart(6, "0")}.png`,
|
||||
`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}`);
|
||||
|
||||
@@ -188,6 +306,9 @@ 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";
|
||||
@@ -195,6 +316,8 @@ async function main(): Promise<void> {
|
||||
|
||||
try {
|
||||
await runWebmCheck(workRoot);
|
||||
await runGifCheck(workRoot);
|
||||
await runGifShaderTransitionCheck(workRoot);
|
||||
await runPngSequenceCheck(workRoot);
|
||||
console.log("\nAll transparency assertions passed.");
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Transparency Regression",
|
||||
"description": "Asserts that webm + png-sequence outputs preserve a real alpha channel end-to-end. Exercised by `tsx src/transparency-test.ts`, NOT by the standard regression harness (which compares against a golden MP4).",
|
||||
"description": "Asserts that webm + gif + png-sequence outputs preserve transparent pixels end-to-end. Exercised by `tsx src/transparency-test.ts`, NOT by the standard regression harness (which compares against a golden MP4).",
|
||||
"tags": ["transparency", "alpha", "smoke"],
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</head>
|
||||
<body
|
||||
data-composition-id="transparency-regression"
|
||||
data-no-timeline
|
||||
data-duration="1"
|
||||
data-width="200"
|
||||
data-height="200"
|
||||
|
||||
Reference in New Issue
Block a user