mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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`.
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
/**
|
||
* 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 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;
|
||
});
|