Files
hyperframes/packages/producer/src/transparency-test.ts
T
Theodor Kleynhans 5dcc89c930 feat(cli): accept ffmpeg-style rational fps (NTSC, PAL, slow-mo)
Replaces the rigid `--fps 24|30|60` whitelist with a numeric range and
adds support for ffmpeg-style fractional framerates so NTSC stays exact
end-to-end.

- `--fps 30` keeps working (integer fps)
- `--fps 30000/1001` now means exact NTSC 29.97 (not the lossy decimal)
- `--fps 24000/1001`, `--fps 60000/1001`, `--fps 25/50/120/240` all work
- Decimals like `--fps 29.97` are rejected with a friendly error pointing
  the user at the rational form, since `29.97` and `30000/1001` round
  to different framerates inside ffmpeg

Carries an `Fps = { num: number; den: number }` rational end-to-end:
RenderConfig, EncoderOptions, StreamingEncoderOptions, CaptureOptions,
DockerRenderOptions, Studio API request body, regression-harness
meta.json. The `-r` and `-framerate` ffmpeg args emit the rational form
verbatim (`30000/1001`) so no decimal round-trip happens at the encoder
boundary. Frame-interval math uses `1000 * den / num` ms (33.366… for
NTSC, 33.333… for integer 30).

Helpers live in @hyperframes/core:
- `parseFps(input: string | number): FpsParseResult` — discriminated
  parser used by both the CLI and the Studio API route
- `fpsToFfmpegArg(fps: Fps): string` — emits "30" or "30000/1001"
- `fpsToNumber(fps: Fps): number` — for arithmetic (telemetry, frame
  count, frame-index → time)

Studio API wire format accepts polymorphic `fps: number | string`:
- number → integer fps (`30`)
- string → rational (`"30000/1001"`)
Decimals are rejected; matches the same rule as the CLI.

Existing meta.json fixtures with integer `"fps": 30` continue to load
unchanged — the regression-harness validator now normalizes both number
and string inputs through `parseFps`.
2026-05-09 00:09:15 +02:00

216 lines
7.5 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 + 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: import("@hyperframes/core").Fps = { num: 30, den: 1 };
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 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;
});