mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
docs(lambda): document webm support + simplify-review fixes (#953)
* docs(lambda): document webm support in distributed mode PR 8.4 of the WebM distributed-rendering plan (v1.5 backlog #1; see DISTRIBUTED-RENDERING-PLAN.md §7.2). User-facing docs catch up with the shipped capability. Updates docs/deploy/migrating-to-hyperframes-lambda.mdx: - "Output format" row in the migration table now lists `webm` alongside mp4 / mov / png-sequence with a note that webm uses libvpx-vp9 + closed-GOP concat-copy. HDR mp4 remains the only refused format. - "No webm distributed" caveat replaced with "webm uses closed-GOP VP9" explainer covering the encoder args (`-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, `-cpu-used 2`), why alt-ref disable is load-bearing, and that the output preserves alpha via yuva420p with Opus audio. - Migration checklist no longer asks adopters to filter out webm compositions; only HDR-dependent renders need to stay on the previous framework. aws-lambda.mdx doesn't currently call out webm as unsupported (only HDR in the v1 surface list), so it gets no copy edits beyond the migration guide. The internal planning doc (DISTRIBUTED-RENDERING-PLAN.md §7.2, §8, §12 — kept outside the repo) gets matching updates: format support matrix flipped ✓, v1.5 backlog #1 marked shipped, HDR promoted to the new top item, and the rev-12 → rev-13 status line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: address simplify-review findings on webm stack Folds in cleanups identified by a multi-agent code-review pass over the 4-PR webm-distributed stack: - plan.ts: `resolveEncoderTriple()` webm case now calls `getEncoderPreset(quality, "webm")` for its preset string instead of hardcoding "good". The hardcode was wrong for `quality: "draft"` (`getEncoderPreset` returns "realtime" for that tier) — would have silently overridden the draft → realtime mapping for distributed webm renders. - chunkEncoder.ts: trim the new VP9 closed-GOP comment block from ~18 lines of WHY narration down to the 6 lines that actually explain why (alt-ref + cpu-used drift). Match the alpha branch's idempotent-push comment to the same standard. - chunkEncoder.test.ts: drop the duplicate WHY comment that restated the implementation comment in plain words. - webm-concat-copy.test.ts: rewrite the file-header docstring to describe the contract being tested instead of the PR-8.1-gating history; strip "PR 8.2 / Path A / Path B" references from error messages (they belong in PR bodies, not in test output). Consolidate the yuva420p alpha smoke into a single `it()` block (was a full 4-test describe with duplicated setup) — the yuv420p block already covers the probe/decode/frame-count contract; the alpha smoke only needs to prove the alpha args don't break concat-copy. - plan.test.ts: drop the "PR 8.1 proved the contract" comment. - webm-vp9 fixture: drop the aspirational "Other webm-with-audio fixtures cover the mux path separately when added" sentence (no other fixtures exist). Regenerated the baseline via `docker:test:update webm-vp9` to reflect the updated comment. - migrating-to-hyperframes-lambda.mdx: add a paragraph about distributed webm's perf cost — ~10-25% larger files at constant CRF due to forced keyframes, and slower per-chunk encode due to `-cpu-used 2` being more conservative than the libvpx default. All unit tests + the webm-vp9 distributed-simulated regression still pass after these changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): accept --format=webm in `hyperframes lambda render` The CLI's `lambda render` subcommand's FORMATS allowlist and the `RenderArgs.format` type still narrowed to `mp4 | mov | png-sequence`, so even though the producer + aws-lambda packages now support webm end-to-end, the CLI surface rejected it with `--format must be mp4|mov| png-sequence`. Add webm to both spots and update the --help description. Surfaced during real-AWS deploy prep — the local lambda-local / distributed-simulated tests didn't go through the CLI so the gap went unnoticed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(producer): font cache writes to /tmp on Lambda (read-only \$HOME) The deterministic Google Fonts cache was rooted at `\$HOME/.cache/hyperframes/fonts`, which fails on AWS Lambda — the runtime's `\$HOME` resolves to a `/home/sbx_*` directory tree that's read-only. `mkdirSync(..., { recursive: true })` can't create that path and the plan stage trips with `ENOENT: no such file or directory, mkdir '/home/sbx_user1051/.cache/hyperframes/fonts/space-mono'` on every Lambda render that pulls a Google Font (i.e. every distributed fixture using `@import url("https://fonts.googleapis.com/...")`). Detect Lambda via `\$AWS_LAMBDA_FUNCTION_NAME` and route the cache to `tmpdir()/hyperframes/fonts` in that case. Lambda's `/tmp` survives across invocations on a warm container, so cache hit rate is the same as non-Lambda runs. Also honor an explicit `\$HYPERFRAMES_FONT_CACHE_DIR` override for adopters who want a different location regardless of the runtime. Surfaced while verifying webm distributed end-to-end on real AWS — the same bug affects mp4 fixtures using Google Fonts; webm just happened to be the one I tried first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: extract DistributedFormat type + trim font-cache resolver Second simplify-review pass on the webm stack flagged two cleanups: 1. **`DistributedFormat` type duplicated 10 times.** Every file in the distributed pipeline carried its own copy of `"mp4" | "mov" | "png-sequence" | "webm"` — adding a new format meant a 10-place edit with no compile-time guarantee they stayed in sync. Extract a single source of truth in `packages/producer/src/services/distributed/shared.ts`, re-export from `@hyperframes/producer/distributed` and `@hyperframes/aws-lambda/sdk`, and have all callers pull from there. The aws-lambda `ALLOWED_FORMATS` runtime tuple and the CLI's `FORMATS` tuple now both use `satisfies readonly DistributedFormat[]` so the compiler enforces the runtime allowlist stays in sync with the type. 2. **`deterministicFonts.ts` font-cache resolver was over-commented.** Trim the 7-line block to 4 lines (drop the aspirational "and other read-only-FS execution environments" — only Lambda is detected — and the warm-container `/tmp` persistence narration — anyone reading already knows Lambda /tmp semantics). Collapse the two-step `if (explicit && explicit.length > 0)` into a single nullish-coalesce expression now that the empty-string defensive check is gone (`process.env.X` is `string | undefined`, no third shape to guard against). Out-of-scope skips (called out by the agents, deferred): - In-process `RenderConfig.format` and the in-process CLI's `render.ts` format union still carry their own inline copies. The union happens to coincide today but they're separate concerns — leaving them alone limits this PR's blast radius. - `fontCacheDir(slug)` / `resolveFontCacheRoot()` naming asymmetry flagged as taste; skipping. - Pre-existing redundant `existsSync` before `mkdirSync({ recursive: true })` in `fontCacheDir` — out of scope. All tests + typecheck still pass. Lambda render still works end-to-end (no functional changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(lambda): drop plan-doc reference from migration checklist PR review feedback: source/docs should not mention the distributed-rendering planning doc. Tighten the migration checklist sentence to describe the webm path directly rather than referencing the doc's version label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(producer): split resolveEncoderTriple into mp4 + non-mp4 helpers CI Fallow audit on PR #953 flagged `resolveEncoderTriple` at CRAP 31.6 — the function interleaved (a) mp4 codec validation + dispatch, (b) the non-mp4 codec-rejection throw, and (c) per-format dispatch. Splitting into `resolveMp4EncoderTriple` + `resolveNonMp4EncoderTriple` drops the top-level function's cyclomatic complexity below the threshold while preserving every error message and code path. Behavior unchanged. Also extracts an `EncoderTriple` type alias so the three functions share the return shape declaratively rather than repeating it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6d2569c6bb
commit
5d264e146c
@@ -1,36 +1,20 @@
|
||||
/**
|
||||
* Smoke test for the WebM (VP9) distributed concat-copy path.
|
||||
*
|
||||
* PR 8.1 gating experiment — answers the question:
|
||||
* "Does `buildEncoderArgs(..., { codec: 'vp9', lockGopForChunkConcat: true, gopSize: N })`
|
||||
* produce VP9 chunk files that `ffmpeg -f concat -c copy` can stitch
|
||||
* into a single playable WebM?"
|
||||
* Asserts that `buildEncoderArgs(..., { codec: "vp9",
|
||||
* lockGopForChunkConcat: true, gopSize: N })` produces VP9 chunk files
|
||||
* that `ffmpeg -f concat -c copy` can stitch into a single playable
|
||||
* WebM.
|
||||
*
|
||||
* YES → PR 8.2 ships Path A: drop webm from FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
|
||||
* and wire lockGopForChunkConcat=true through the distributed plan().
|
||||
* Uses direct ffmpeg invocation instead of `plan() / renderChunk() /
|
||||
* assemble()` so the contract this test pins is exactly the encoder-arg
|
||||
* surface — independent of plan-time validation, file servers, browser
|
||||
* capture, and the rest of the distributed-pipeline stack.
|
||||
*
|
||||
* NO → PR 8.2 ships Path B: re-encode the concat'd chunks in `assemble()`
|
||||
* (slower; loses encode parallelism but is reliably correct).
|
||||
*
|
||||
* Why direct ffmpeg invocation (instead of plan/renderChunk/assemble): the
|
||||
* full distributed pipeline currently REFUSES webm at plan time, so we can't
|
||||
* exercise it end-to-end yet. This smoke test bypasses the producer pipeline
|
||||
* and only validates the ffmpeg-level contract — the encoder args we'll wire
|
||||
* into the pipeline in 8.2.
|
||||
*
|
||||
* The test generates 60 frames (2s @ 30fps) of an animated test pattern
|
||||
* (`testsrc2` from ffmpeg's lavfi), splits them into 4 chunks of 15 frames
|
||||
* each via direct `ffmpeg` invocations using the args from
|
||||
* `buildEncoderArgs(..., { lockGopForChunkConcat: true, gopSize: 15 })`,
|
||||
* concat-copies them, and runs three independent verifications:
|
||||
*
|
||||
* 1. `ffprobe -show_streams` — output is a valid WebM with one VP9 stream
|
||||
* 2. `ffmpeg -i ... -f null -` — output decodes cleanly (no seam errors)
|
||||
* 3. `ffprobe -count_frames` — frame count equals sum of chunk frames
|
||||
*
|
||||
* If concat-copy fails in any way the test reports the precise failure
|
||||
* fingerprint in the error message so PR 8.2 has the data it needs to pick
|
||||
* Path A vs Path B.
|
||||
* Each chunk + concat-copy + ffprobe verification surfaces its failure
|
||||
* fingerprint in the error message so a regression-driven concat-copy
|
||||
* failure (alt-ref reaching across a seam, libvpx bumping its default
|
||||
* cpu-used, etc.) can be diagnosed without re-running locally.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
@@ -201,14 +185,13 @@ describe("webm VP9 concat-copy smoke", () => {
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
// Surface ffmpeg's full stderr in the assertion message so 8.2 has the
|
||||
// failure fingerprint when concat-copy is broken (e.g.
|
||||
// "Non-monotonous DTS in output stream", "missing keyframe at chunk 2",
|
||||
// matroska/webm cluster errors).
|
||||
// Surface ffmpeg's full stderr in the assertion message — a broken
|
||||
// concat-copy fails with something specific ("Non-monotonous DTS",
|
||||
// "missing keyframe at chunk 2", matroska/webm cluster errors) that
|
||||
// the message above wouldn't disambiguate.
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[smoke concat-copy] failed (exit ${result.exitCode}). ` +
|
||||
`This means PR 8.2 must take Path B (re-encode in assemble). ` +
|
||||
`Failure fingerprint: ${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
@@ -264,7 +247,7 @@ describe("webm VP9 concat-copy smoke", () => {
|
||||
throw new Error(
|
||||
`[smoke decode-test] ffmpeg -f null - reported decode errors ` +
|
||||
`(exit ${result.exitCode}). This means concat-copy seams produce ` +
|
||||
`invalid VP9 references — PR 8.2 must take Path B (re-encode in assemble). ` +
|
||||
`invalid VP9 references ` +
|
||||
`Failure fingerprint: ${result.stderr.slice(-1000) || "(no stderr; check exit code)"}`,
|
||||
);
|
||||
}
|
||||
@@ -296,9 +279,8 @@ describe("webm VP9 concat-copy smoke", () => {
|
||||
const nbFrames = Number.parseInt(result.stdout.trim(), 10);
|
||||
if (!Number.isFinite(nbFrames) || nbFrames !== TOTAL_FRAMES) {
|
||||
throw new Error(
|
||||
`[smoke ffprobe count_frames] expected ${TOTAL_FRAMES} frames, got ${result.stdout.trim()}. ` +
|
||||
`This means concat-copy dropped frames at one or more chunk seams — ` +
|
||||
`PR 8.2 must take Path B (re-encode in assemble).`,
|
||||
`[smoke ffprobe count_frames] expected ${TOTAL_FRAMES} frames, got ${result.stdout.trim()} ` +
|
||||
`— concat-copy dropped frames at one or more chunk seams.`,
|
||||
);
|
||||
}
|
||||
expect(nbFrames).toBe(TOTAL_FRAMES);
|
||||
@@ -306,231 +288,195 @@ describe("webm VP9 concat-copy smoke", () => {
|
||||
});
|
||||
|
||||
describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => {
|
||||
// The wired-up distributed webm path uses yuva420p, not yuv420p — that
|
||||
// matches the in-process renderer's webm pixel format (alpha video, the
|
||||
// format's main reason for existing). yuva420p VP9 streams have a few
|
||||
// extra concat-copy hazards that yuv420p doesn't (the alpha sub-stream
|
||||
// is muxed via `-metadata:s:v:0 alpha_mode=1` and concat-copy must
|
||||
// preserve that metadata across chunks).
|
||||
//
|
||||
// This block re-runs the same three verifications on yuva420p output to
|
||||
// pin the contract for what the distributed pipeline actually emits.
|
||||
let alphaRoot: string;
|
||||
let alphaFramesDir: string;
|
||||
let alphaChunkDir: string;
|
||||
let alphaConcatListPath: string;
|
||||
let alphaOutputPath: string;
|
||||
// The wired-up distributed webm path uses yuva420p. This block proves
|
||||
// (a) the closed-GOP args + alpha pixel format don't break concat-copy
|
||||
// at the bitstream level, and (b) the alpha plane round-trips with
|
||||
// real spatial content — catching the failure mode where the encoder
|
||||
// accepted yuva420p input but dropped the alpha sub-stream silently.
|
||||
// The source frames carry a per-pixel alpha gradient so the encoder
|
||||
// cannot treat the alpha plane as uniform/redundant and drop it.
|
||||
it("encode + concat-copy + decode round-trip works for yuva420p", () => {
|
||||
const alphaRoot = mkdtempSync(join(tmpdir(), "hf-webm-concat-smoke-alpha-"));
|
||||
try {
|
||||
const alphaFramesDir = join(alphaRoot, "frames");
|
||||
const alphaChunkDir = join(alphaRoot, "chunks");
|
||||
mkdirSync(alphaFramesDir, { recursive: true });
|
||||
mkdirSync(alphaChunkDir, { recursive: true });
|
||||
const alphaConcatListPath = join(alphaRoot, "concat-list.txt");
|
||||
const alphaOutputPath = join(alphaRoot, "output.webm");
|
||||
|
||||
beforeAll(() => {
|
||||
alphaRoot = mkdtempSync(join(tmpdir(), "hf-webm-concat-smoke-alpha-"));
|
||||
alphaFramesDir = join(alphaRoot, "frames");
|
||||
alphaChunkDir = join(alphaRoot, "chunks");
|
||||
mkdirSync(alphaFramesDir, { recursive: true });
|
||||
mkdirSync(alphaChunkDir, { recursive: true });
|
||||
alphaConcatListPath = join(alphaRoot, "concat-list.txt");
|
||||
alphaOutputPath = join(alphaRoot, "output.webm");
|
||||
|
||||
// For alpha frames, generate RGBA PNGs with spatially-varying alpha
|
||||
// so the encoder can't drop the alpha plane as uniform/redundant.
|
||||
// `testsrc2 + format=rgba` (the prior shape) produced uniformly-
|
||||
// opaque alpha and the libvpx-vp9 encoder silently downgraded the
|
||||
// output to yuv420p — masking any bug in the alpha pipeline. Here
|
||||
// `geq=a='X*255/W'` writes a horizontal alpha gradient on top of
|
||||
// the testsrc2 RGB so the alpha track has real per-pixel content.
|
||||
const frameGen = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc2=s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${TOTAL_FRAMES / FPS}`,
|
||||
"-vf",
|
||||
"format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*255/W'",
|
||||
"-frames:v",
|
||||
String(TOTAL_FRAMES),
|
||||
join(alphaFramesDir, "frame_%04d.png"),
|
||||
]);
|
||||
if (frameGen.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke setup] frame generation failed (exit ${frameGen.exitCode}): ` +
|
||||
frameGen.stderr.slice(-400),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(alphaRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("encodes 4 yuva420p VP9 chunks with closed-GOP args", () => {
|
||||
for (let chunkIdx = 0; chunkIdx < CHUNK_COUNT; chunkIdx++) {
|
||||
const startNumber = chunkIdx * CHUNK_SIZE + 1;
|
||||
const chunkPath = join(alphaChunkDir, `chunk_${String(chunkIdx).padStart(4, "0")}.webm`);
|
||||
const inputArgs = [
|
||||
"-framerate",
|
||||
String(FPS),
|
||||
"-start_number",
|
||||
String(startNumber),
|
||||
// `geq=a='X*255/W'` writes a horizontal alpha gradient on top of
|
||||
// the testsrc2 RGB. `testsrc2 + format=rgba` alone produced
|
||||
// uniformly-opaque alpha and libvpx-vp9 silently downgraded the
|
||||
// output to yuv420p, masking any alpha-pipeline bug — the
|
||||
// gradient ensures the encoder has spatially-varying alpha to
|
||||
// preserve.
|
||||
const frameGen = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
join(alphaFramesDir, "frame_%04d.png"),
|
||||
`testsrc2=s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${TOTAL_FRAMES / FPS}`,
|
||||
"-vf",
|
||||
"format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*255/W'",
|
||||
"-frames:v",
|
||||
String(CHUNK_SIZE),
|
||||
];
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
fps: { num: FPS, den: 1 },
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
codec: "vp9",
|
||||
preset: "good",
|
||||
quality: 32,
|
||||
// yuva420p is what the distributed pipeline actually emits for
|
||||
// webm; the alpha branch in chunkEncoder.ts adds the
|
||||
// `-metadata:s:v:0 alpha_mode=1` tag we want to verify
|
||||
// round-trips through concat-copy.
|
||||
pixelFormat: "yuva420p",
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: CHUNK_SIZE,
|
||||
},
|
||||
inputArgs,
|
||||
chunkPath,
|
||||
);
|
||||
const result = runFfmpegSync(["-hide_banner", "-loglevel", "error", ...args]);
|
||||
if (result.exitCode !== 0) {
|
||||
String(TOTAL_FRAMES),
|
||||
join(alphaFramesDir, "frame_%04d.png"),
|
||||
]);
|
||||
if (frameGen.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke chunk ${chunkIdx}] yuva420p VP9 encode failed (exit ${result.exitCode}):\n` +
|
||||
`args: ${JSON.stringify(args)}\n` +
|
||||
`stderr: ${result.stderr.slice(-1000)}`,
|
||||
`[alpha smoke setup] frame generation failed: ${frameGen.stderr.slice(-400)}`,
|
||||
);
|
||||
}
|
||||
expect(existsSync(chunkPath)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("concat-copies the 4 yuva420p chunks into a single alpha WebM", () => {
|
||||
const lines: string[] = [];
|
||||
for (let chunkIdx = 0; chunkIdx < CHUNK_COUNT; chunkIdx++) {
|
||||
const chunkPath = join(alphaChunkDir, `chunk_${String(chunkIdx).padStart(4, "0")}.webm`);
|
||||
lines.push(`file '${chunkPath.replace(/'/g, "'\\''")}'`);
|
||||
}
|
||||
writeFileSync(alphaConcatListPath, `${lines.join("\n")}\n`, "utf-8");
|
||||
const chunkPaths: string[] = [];
|
||||
for (let chunkIdx = 0; chunkIdx < CHUNK_COUNT; chunkIdx++) {
|
||||
const startNumber = chunkIdx * CHUNK_SIZE + 1;
|
||||
const chunkPath = join(alphaChunkDir, `chunk_${String(chunkIdx).padStart(4, "0")}.webm`);
|
||||
chunkPaths.push(chunkPath);
|
||||
const args = buildEncoderArgs(
|
||||
{
|
||||
fps: { num: FPS, den: 1 },
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
codec: "vp9",
|
||||
preset: "good",
|
||||
quality: 32,
|
||||
pixelFormat: "yuva420p",
|
||||
lockGopForChunkConcat: true,
|
||||
gopSize: CHUNK_SIZE,
|
||||
},
|
||||
[
|
||||
"-framerate",
|
||||
String(FPS),
|
||||
"-start_number",
|
||||
String(startNumber),
|
||||
"-i",
|
||||
join(alphaFramesDir, "frame_%04d.png"),
|
||||
"-frames:v",
|
||||
String(CHUNK_SIZE),
|
||||
],
|
||||
chunkPath,
|
||||
);
|
||||
const result = runFfmpegSync(["-hide_banner", "-loglevel", "error", ...args]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke chunk ${chunkIdx}] yuva420p VP9 encode failed: ${result.stderr.slice(-1000)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
alphaConcatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
alphaOutputPath,
|
||||
]);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke concat-copy] failed (exit ${result.exitCode}). ` +
|
||||
`yuva420p webm concat-copy is broken — PR 8.2 must take Path B. ` +
|
||||
`Failure fingerprint: ${result.stderr.slice(-1000)}`,
|
||||
writeFileSync(
|
||||
alphaConcatListPath,
|
||||
`${chunkPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n")}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
expect(existsSync(alphaOutputPath)).toBe(true);
|
||||
expect(statSync(alphaOutputPath).size).toBeGreaterThan(0);
|
||||
});
|
||||
const concatResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
alphaConcatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
alphaOutputPath,
|
||||
]);
|
||||
if (concatResult.exitCode !== 0) {
|
||||
throw new Error(`[alpha smoke concat-copy] failed: ${concatResult.stderr.slice(-1000)}`);
|
||||
}
|
||||
|
||||
it("decodes alpha-track WebM cleanly without seam errors", () => {
|
||||
const decodeResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
alphaOutputPath,
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
// Gate only on exit code — `-v error` ffmpeg builds can emit
|
||||
// non-fatal stderr (DTS warnings, container-quirk notes) and we
|
||||
// don't want the test to flake on chatty stderr in a future
|
||||
// libavformat upgrade. Surface stderr in the failure message for
|
||||
// forensic context.
|
||||
if (decodeResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke decode-test] failed (exit ${decodeResult.exitCode}). ` +
|
||||
`Failure fingerprint: ${decodeResult.stderr.slice(-1000) || "(no stderr)"}`,
|
||||
);
|
||||
}
|
||||
// Decode-test gates only on exit code — `-v error` ffmpeg builds
|
||||
// can emit non-fatal stderr (DTS warnings, container-quirk notes)
|
||||
// and we don't want the test to flake on chatty stderr in a
|
||||
// future libavformat upgrade.
|
||||
const decodeResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
alphaOutputPath,
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
if (decodeResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke decode-test] failed (exit ${decodeResult.exitCode}): ` +
|
||||
`${decodeResult.stderr.slice(-1000) || "(no stderr)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const probeResult = runFfprobeSync([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_streams",
|
||||
alphaOutputPath,
|
||||
]);
|
||||
expect(probeResult.exitCode).toBe(0);
|
||||
expect(probeResult.stdout).toMatch(/codec_name=vp9/);
|
||||
// libvpx-vp9 stores the alpha plane as a Matroska `BlockAdditional`
|
||||
// sidecar, NOT in the main stream's `pix_fmt` — so `ffprobe` always
|
||||
// reports `pix_fmt=yuv420p` for VP9-with-alpha. The right signal that
|
||||
// alpha encoding was enabled is the stream-level `TAG:ALPHA_MODE=1`
|
||||
// tag the encoder writes when `-metadata:s:v:0 alpha_mode=1` is set
|
||||
// on a yuva420p input.
|
||||
expect(probeResult.stdout).toMatch(/ALPHA_MODE=1/);
|
||||
});
|
||||
// libvpx-vp9 stores the alpha plane as a Matroska `BlockAdditional`
|
||||
// sidecar, NOT in the main stream's `pix_fmt` — `ffprobe` always
|
||||
// reports `pix_fmt=yuv420p` for VP9-with-alpha. The right signal
|
||||
// is the stream-level `TAG:ALPHA_MODE=1` tag the encoder writes
|
||||
// when `-metadata:s:v:0 alpha_mode=1` is set on yuva420p input.
|
||||
const probeResult = runFfprobeSync([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_streams",
|
||||
alphaOutputPath,
|
||||
]);
|
||||
expect(probeResult.exitCode).toBe(0);
|
||||
expect(probeResult.stdout).toMatch(/codec_name=vp9/);
|
||||
expect(probeResult.stdout).toMatch(/ALPHA_MODE=1/);
|
||||
|
||||
it("alpha plane round-trips through concat-copy with spatially-varying content", () => {
|
||||
// Decode the concat-copied WebM via the libvpx-vp9 decoder forced to
|
||||
// RGBA, then extract the alpha plane and check it has real spatial
|
||||
// variance — catches the failure mode where the encoder accepted
|
||||
// yuva420p input but dropped the alpha sub-stream silently
|
||||
// (uniform alpha would mask any plan-time bug like the `needsAlpha`
|
||||
// hole that hid this PR's bug before review caught it). The
|
||||
// gradient source produces YMIN ≈ 0 / YMAX ≈ 255 on the alpha
|
||||
// plane; uniform alpha would give YMIN == YMAX. Spread > 100 is a
|
||||
// generous floor that catches the bad case cleanly.
|
||||
//
|
||||
// `-c:v libvpx-vp9` before `-i` is the load-bearing piece: ffmpeg's
|
||||
// default VP9 decoder path strips the BlockAdditional alpha track
|
||||
// when decoding to non-rgba pixel formats; forcing the libvpx-vp9
|
||||
// decoder + `-pix_fmt rgba` is how we get the alpha plane back.
|
||||
const statsResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-i",
|
||||
alphaOutputPath,
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-vf",
|
||||
"extractplanes=a,signalstats,metadata=mode=print:file=-",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
if (statsResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke signalstats] failed (exit ${statsResult.exitCode}): ` +
|
||||
`${statsResult.stderr.slice(-500)}`,
|
||||
);
|
||||
// Decode the alpha plane and check it has spatially-varying
|
||||
// content — catches the case where the encoder accepted yuva420p
|
||||
// input but dropped the alpha sub-stream silently (a uniform
|
||||
// alpha plane would mask any plan-time bug like a misconfigured
|
||||
// `needsAlpha` gate). The horizontal gradient source produces
|
||||
// YMIN ≈ 0 / YMAX ≈ 255 on the alpha plane; uniform alpha would
|
||||
// give YMIN == YMAX. Spread > 100 cleanly rejects the bad case.
|
||||
//
|
||||
// `-c:v libvpx-vp9` before `-i` is load-bearing: ffmpeg's default
|
||||
// VP9 decoder strips the BlockAdditional alpha track when
|
||||
// decoding to non-rgba pixel formats; forcing the libvpx-vp9
|
||||
// decoder + `-pix_fmt rgba` is how the alpha plane comes back.
|
||||
const statsResult = runFfmpegSync([
|
||||
"-hide_banner",
|
||||
"-v",
|
||||
"error",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-i",
|
||||
alphaOutputPath,
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-vf",
|
||||
"extractplanes=a,signalstats,metadata=mode=print:file=-",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
]);
|
||||
if (statsResult.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[alpha smoke signalstats] failed (exit ${statsResult.exitCode}): ` +
|
||||
`${statsResult.stderr.slice(-500)}`,
|
||||
);
|
||||
}
|
||||
const yminMatch = statsResult.stdout.match(/lavfi\.signalstats\.YMIN=(\d+)/);
|
||||
const ymaxMatch = statsResult.stdout.match(/lavfi\.signalstats\.YMAX=(\d+)/);
|
||||
if (!yminMatch || !ymaxMatch) {
|
||||
throw new Error(
|
||||
`[alpha smoke signalstats] could not parse YMIN/YMAX from output: ` +
|
||||
`${statsResult.stdout.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
const ymin = Number.parseInt(yminMatch[1], 10);
|
||||
const ymax = Number.parseInt(ymaxMatch[1], 10);
|
||||
expect(ymax - ymin).toBeGreaterThan(100);
|
||||
expect(statSync(alphaOutputPath).size).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(alphaRoot, { recursive: true, force: true });
|
||||
}
|
||||
const yminMatch = statsResult.stdout.match(/lavfi\.signalstats\.YMIN=(\d+)/);
|
||||
const ymaxMatch = statsResult.stdout.match(/lavfi\.signalstats\.YMAX=(\d+)/);
|
||||
if (!yminMatch || !ymaxMatch) {
|
||||
throw new Error(
|
||||
`[alpha smoke signalstats] could not parse YMIN/YMAX from output: ` +
|
||||
`${statsResult.stdout.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
const ymin = Number.parseInt(yminMatch[1], 10);
|
||||
const ymax = Number.parseInt(ymaxMatch[1], 10);
|
||||
expect(ymax - ymin).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user