From 140e4268520c3b92a122339fb0d7b309800db0c8 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 19 May 2026 01:13:47 +0000 Subject: [PATCH 1/3] feat(producer): enable webm in distributed mode via concat-copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 8.2 of the WebM distributed-rendering plan (v1.5 backlog #1; see DISTRIBUTED-RENDERING-PLAN.md §7.2). Wires libvpx-vp9 webm through the distributed pipeline now that PR 8.1 proved concat-copy works. Architectural decision: Path A (concat-copy) — based on PR 8.1's smoke test result (9/9 tests pass for both yuv420p and yuva420p VP9 streams). The simpler architecture wins; no re-encode in assemble, no encode- parallelism loss. Changes: - plan.ts: - DistributedRenderConfig.format and PlanResult.format now include "webm" — type-level acceptance matches the runtime gate. - rejectUnsupportedDistributedFormat() no longer trips on webm. HDR mp4 remains the only refused configuration. - resolveEncoderTriple() returns libvpx-vp9-software + yuva420p + preset="good" for format="webm". yuva420p preserves alpha — the format's main reason for existing for web delivery. - codec= remains rejected for non-mp4 formats (mov is always ProRes 4444; webm is always libvpx-vp9). The error message lists all four distributed-supported formats. - FormatNotSupportedInDistributedError docstring updated to reflect the new reality (only HDR is unsupported). - freezePlan.ts: LockedRenderConfig.encoder gains "libvpx-vp9-software". Mirrors libx265-software / prores-software / png-sequence in shape; the chunk worker reads this discriminant to decide encode args. - renderChunk.ts: drops the now-incorrect cast that excluded webm from buildSyntheticRenderJob's format input; tightens the preset-format cast to include webm. - assemble.ts: docstring + comment updates. The mp4/mov concat-copy path is format-agnostic — webm uses the exact same code (applyFaststart is a no-op for webm via the existing chunkEncoder.ts gate; muxVideoWithAudio already routes webm to libopus audio). - planFormatBanlist.test.ts: webm-rejection tests removed; replaced with "accepts webm" tests + a HDR+webm combo test that verifies HDR is the trip regardless of format. - plan.test.ts: new describe block pins the webm wiring contract: format="webm" produces an encoder=libvpx-vp9-software / pixelFormat=yuva420p planDir with closedGop=true and gopSize=chunkSize. - webm-concat-copy.test.ts (smoke): extended with a yuva420p variant that proves the alpha pixel format the distributed pipeline actually emits also round-trips through concat-copy. 9/9 tests pass locally. §8 format support matrix in DISTRIBUTED-RENDERING-PLAN.md is intentionally left unchanged at this PR — it flips to ✓ in PR 8.4 once the end-to-end fixture (PR 8.3) is green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/distributed/assemble.ts | 22 ++- .../src/services/distributed/plan.test.ts | 55 ++++++ .../producer/src/services/distributed/plan.ts | 65 ++++--- .../distributed/planFormatBanlist.test.ts | 62 +++--- .../src/services/distributed/renderChunk.ts | 18 +- .../src/services/render/stages/freezePlan.ts | 7 +- .../_smoke/webm-concat-copy.test.ts | 180 ++++++++++++++++++ 7 files changed, 323 insertions(+), 86 deletions(-) diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 728f122cf..ec47f1178 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -2,16 +2,20 @@ * Activity C of the distributed render pipeline. * * `assemble(planDir, chunkPaths, audioPath, outputPath)` stitches per-chunk - * outputs into the final deliverable. For mp4/mov this is `ffmpeg -f concat - * -c copy` (free of re-encode loss because every chunk's first frame is an - * IDR keyframe — the chunk encoder sets `lockGopForChunkConcat` to - * enforce this). For png-sequence chunks (each chunk is a directory of + * outputs into the final deliverable. For mp4 / mov / webm this is + * `ffmpeg -f concat -c copy` (free of re-encode loss because every + * chunk's first frame is an IDR keyframe — the chunk encoder sets + * `lockGopForChunkConcat` to enforce this, which for libvpx-vp9 also + * disables alt-ref frames so concat seams remain independently + * decodable). For png-sequence chunks (each chunk is a directory of * frames) this is a straight directory merge with global re-numbering. * - * Mux + faststart for mp4/mov go through the engine's `muxVideoWithAudio` - * + `applyFaststart` helpers — same path the in-process renderer uses; we - * just feed concat output rather than streaming-encoder output. Audio - * length is pad-or-trimmed to `frameCount / fps` via + * Mux + faststart for mp4 / mov / webm go through the engine's + * `muxVideoWithAudio` + `applyFaststart` helpers — same path the + * in-process renderer uses; we just feed concat output rather than + * streaming-encoder output. (Faststart is a no-op for webm and mov — + * applyFaststart copies the input verbatim.) Audio length is + * pad-or-trimmed to `frameCount / fps` via * `padOrTrimAudioToVideoFrameCount` so the mux step doesn't introduce * sub-millisecond drift at the end of long renders. * @@ -116,7 +120,7 @@ export async function assemble( return mergePngFrameDirs(chunkPaths, outputPath, plan.totalFrames, audioPath, start); } - // ── 2b. mp4 / mov: concat-copy then mux + faststart ──────────────────── + // ── 2b. mp4 / mov / webm: concat-copy then mux + faststart ──────────── if (!existsSync(dirname(outputPath))) { mkdirSync(dirname(outputPath), { recursive: true }); } diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 2106a90b3..462cf77d8 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -460,3 +460,58 @@ describe("plan() — codec knob", () => { expect((caught as Error).message).toMatch(/codec must be "h264" or "h265"/); }); }); + +describe("plan() — webm format (distributed VP9)", () => { + const TIMEOUT_MS = 30_000; + + it( + 'maps `format: "webm"` to libvpx-vp9-software + yuva420p', + async () => { + // Webm is distributed-supported via closed-GOP concat-copy (PR 8.1 + // proved the contract; this test pins the plan-time encoder choice). + // yuva420p preserves the format's reason for existing — alpha video + // for web playback over colored backgrounds. + const planDir = join(runRoot, "plan-webm-vp9"); + mkdirSync(planDir, { recursive: true }); + const result = await plan( + projectDir, + { fps: 30, width: 320, height: 240, format: "webm" }, + planDir, + ); + expect(result.format).toBe("webm"); + + const encoder = JSON.parse( + readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"), + ) as Record; + expect(encoder.encoder).toBe("libvpx-vp9-software"); + expect(encoder.pixelFormat).toBe("yuva420p"); + // Closed-GOP must be on so concat-copy at assemble time works. + // gopSize equals the chunkSize so every chunk's first frame is a + // keyframe with no alt-ref references reaching back across seams. + expect(encoder.closedGop).toBe(true); + expect(encoder.gopSize).toBe(encoder.chunkSize); + }, + TIMEOUT_MS, + ); + + it("rejects `codec` with format=webm", async () => { + // webm is always libvpx-vp9 — same shape as mov (always ProRes 4444). + // A JS caller building config from JSON who passes `codec: "vp8"` or + // `codec: "av1"` must hit a typed error, not silently encode VP9. + const planDir = join(runRoot, "plan-webm-bad-codec"); + mkdirSync(planDir, { recursive: true }); + let caught: unknown; + try { + await plan( + projectDir, + // @ts-expect-error — runtime check is the test's purpose. + { fps: 30, width: 320, height: 240, format: "webm", codec: "vp8" }, + planDir, + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/); + }); +}); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 821bc27b2..21cc78636 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -74,12 +74,19 @@ export interface DistributedRenderConfig { width: number; height: number; /** - * Output container format. webm and HDR mp4 are not supported in - * distributed mode — `plan()` refuses them up front with a typed + * Output container format. HDR mp4 is not supported in distributed + * mode — `plan()` refuses it up front with a typed * `FormatNotSupportedInDistributedError`. The in-process renderer - * supports both. + * supports it. + * + * `"webm"` (VP9 + Opus) is distributed-supported via closed-GOP + * concat-copy: `lockGopForChunkConcat=true` forces a keyframe at every + * chunk boundary and disables libvpx-vp9's alt-ref frames so chunk + * files stitch losslessly. See `chunkEncoder.ts` for the VP9 args and + * `tests/distributed/_smoke/webm-concat-copy.test.ts` for the gating + * experiment that proved the contract. */ - format: "mp4" | "mov" | "png-sequence"; + format: "mp4" | "mov" | "png-sequence" | "webm"; /** * Codec selection for `format: "mp4"`. `"h264"` (the default) → libx264 + * yuv420p; `"h265"` → libx265 + yuv420p with closed-GOP keyint params @@ -169,7 +176,7 @@ export interface PlanResult { fps: 24 | 30 | 60; width: number; height: number; - format: "mp4" | "mov" | "png-sequence"; + format: "mp4" | "mov" | "png-sequence" | "webm"; ffmpegVersion: string; producerVersion: string; } @@ -247,8 +254,9 @@ export class PlanTooLargeError extends Error { /** * Non-retryable error code raised when `plan()` is asked for an output - * format that distributed mode doesn't support (webm, HDR mp4). The same - * config would fail on every retry, so the failure must not auto-retry. + * format that distributed mode doesn't support (currently: HDR mp4). The + * same config would fail on every retry, so the failure must not + * auto-retry. */ export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED"; @@ -256,13 +264,15 @@ export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DIST * Typed error raised by `plan()` for outputs that distributed mode * refuses to ship. * - * - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9 - * builds, and the chunked pipeline can't guarantee bit-identical - * concat output across worker versions. * - mp4 + HDR (PQ / HLG) — chunked HDR pre-extract + HDR signaling * re-apply on the assembled file is not implemented yet. * - * The in-process renderer (`executeRenderJob`) handles both natively. + * The in-process renderer (`executeRenderJob`) handles it natively. + * + * WebM was previously refused here; v0.7+ supports it via closed-GOP + * concat-copy. See {@link DistributedRenderConfig.format} for the + * supported set and {@link rejectUnsupportedDistributedFormat} for the + * gate. */ export class FormatNotSupportedInDistributedError extends Error { readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED; @@ -272,8 +282,8 @@ export class FormatNotSupportedInDistributedError extends Error { super( `[plan] format ${JSON.stringify(format)} is not supported in distributed mode: ${reason}. ` + `Render with the in-process renderer (\`executeRenderJob\`) — it has full format ` + - `support — or pick a distributed-supported format: mp4 SDR, mov ProRes 4444, or ` + - `png-sequence.`, + `support — or pick a distributed-supported format: mp4 SDR, mov ProRes 4444, ` + + `png-sequence, or webm VP9.`, ); this.name = "FormatNotSupportedInDistributedError"; this.format = format; @@ -289,7 +299,9 @@ function formatBytes(bytes: number): string { } /** - * Reject formats the distributed pipeline cannot ship (webm + HDR mp4). + * Reject formats the distributed pipeline cannot ship (HDR mp4 only — + * webm is supported as of v0.7 via closed-GOP concat-copy). + * * Throws {@link FormatNotSupportedInDistributedError} with a message * naming the rejected format. Runs at the very top of `plan()` so a * banned input never produces a partial planDir. @@ -302,17 +314,6 @@ function formatBytes(bytes: number): string { export function rejectUnsupportedDistributedFormat( config: Pick, ): void { - // The TypeScript type for `DistributedRenderConfig.format` already - // excludes webm, but a JS caller (or a caller that built the config - // dynamically from JSON) can still pass it. Belt-and-suspenders runtime - // check at the gate. - if ((config.format as string) === "webm") { - throw new FormatNotSupportedInDistributedError( - "webm", - "VP9 + matroska concat-copy is fragile across libvpx-vp9 builds, so chunked output " + - "can't be guaranteed byte-identical across workers", - ); - } if ((config.hdrMode as string) === "force-hdr") { throw new FormatNotSupportedInDistributedError( "mp4-hdr", @@ -513,7 +514,8 @@ function buildLockedRenderConfig(input: { /** * Resolve the encoder + pixel-format + preset triple for a distributed * render. Distributed mode is SDR-only: H.264 or H.265 8-bit for mp4, - * ProRes 4444 for mov, raw RGBA for png-sequence. + * libvpx-vp9 + yuva420p (alpha) for webm, ProRes 4444 for mov, raw RGBA + * for png-sequence. * * `config.codec` is consulted only when `config.format === "mp4"`. Passing * `codec` with a non-mp4 format throws at plan time — surfaces the @@ -547,12 +549,21 @@ function resolveEncoderTriple(config: DistributedRenderConfig): { throw new Error( `[plan] DistributedRenderConfig.codec is only valid for format="mp4"; received ` + `codec=${JSON.stringify(config.codec)} with format=${JSON.stringify(config.format)}. ` + - `Omit codec for non-mp4 formats — mov is always ProRes 4444 and png-sequence has no encoder.`, + `Omit codec for non-mp4 formats — mov is always ProRes 4444, webm is always ` + + `libvpx-vp9, and png-sequence has no encoder.`, ); } if (config.format === "mov") { return { encoder: "prores-software", pixelFormat: "yuva444p10le", preset: "4444" }; } + if (config.format === "webm") { + // webm distributes via closed-GOP libvpx-vp9 + concat-copy. yuva420p + // matches the in-process renderer's webm pixel format (alpha-capable + // — the format's main reason for existing). `getEncoderPreset` in + // the engine returns "good" for non-draft quality tiers; that becomes + // libvpx-vp9's `-deadline good` at encode time. + return { encoder: "libvpx-vp9-software", pixelFormat: "yuva420p", preset: "good" }; + } return { encoder: "png-sequence", pixelFormat: "rgba", preset: "lossless" }; } diff --git a/packages/producer/src/services/distributed/planFormatBanlist.test.ts b/packages/producer/src/services/distributed/planFormatBanlist.test.ts index b0bcceed5..4d31dda81 100644 --- a/packages/producer/src/services/distributed/planFormatBanlist.test.ts +++ b/packages/producer/src/services/distributed/planFormatBanlist.test.ts @@ -1,15 +1,18 @@ /** * Unit tests for the distributed format banlist. * - * Two formats `plan()` refuses up front: - * - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9 builds. + * `plan()` refuses one configuration up front: * - mp4 + HDR (`hdrMode === "force-hdr"`) — chunked HDR pre-extract + * HDR signaling re-apply on the assembled file is not implemented. * * The banlist must trip BEFORE any other work runs (file server, browser, * ffprobe) — otherwise a banned config can leak a partial planDir on disk. - * Each case asserts `existsSync(planDir)` is `false` after the throw to - * pin the early-exit contract. + * The HDR case asserts `existsSync(planDir)` is `false` after the throw + * to pin the early-exit contract. + * + * WebM was previously refused here; v0.7+ supports it via closed-GOP + * concat-copy. The "accepts webm" tests below pin the contract that + * `rejectUnsupportedDistributedFormat` no longer trips on webm. */ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; @@ -44,24 +47,28 @@ afterAll(() => { }); describe("rejectUnsupportedDistributedFormat (pure)", () => { - it("accepts the v1-supported formats (mp4 / mov / png-sequence)", () => { + it("accepts the v1-supported formats (mp4 / mov / png-sequence / webm)", () => { expect(() => rejectUnsupportedDistributedFormat({ format: "mp4" })).not.toThrow(); expect(() => rejectUnsupportedDistributedFormat({ format: "mov" })).not.toThrow(); expect(() => rejectUnsupportedDistributedFormat({ format: "png-sequence" })).not.toThrow(); + expect(() => rejectUnsupportedDistributedFormat({ format: "webm" })).not.toThrow(); expect(() => rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "auto" }), ).not.toThrow(); expect(() => rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "force-sdr" }), ).not.toThrow(); + expect(() => + rejectUnsupportedDistributedFormat({ format: "webm", hdrMode: "force-sdr" }), + ).not.toThrow(); }); - it("rejects webm with FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", () => { + it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => { let caught: unknown; try { - // Cast forces the runtime check even though the type narrows webm out. rejectUnsupportedDistributedFormat({ - format: "webm" as DistributedRenderConfig["format"], + format: "mp4", + hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"], }); } catch (err) { caught = err; @@ -70,56 +77,31 @@ describe("rejectUnsupportedDistributedFormat (pure)", () => { expect((caught as FormatNotSupportedInDistributedError).code).toBe( FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED, ); - expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm"); - expect((caught as Error).message).toMatch(/webm/); - expect((caught as Error).message).toMatch(/in-process|executeRenderJob/); + expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr"); + expect((caught as Error).message).toMatch(/HDR/); }); - it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => { + it("rejects HDR + webm combination (HDR is the trip, not webm)", () => { + // Belt-and-suspenders: even when webm is the format, force-hdr must + // still throw — distributed HDR is unimplemented regardless of format. let caught: unknown; try { rejectUnsupportedDistributedFormat({ - format: "mp4", + format: "webm", hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"], }); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError); - expect((caught as FormatNotSupportedInDistributedError).code).toBe( - FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED, - ); expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr"); - expect((caught as Error).message).toMatch(/HDR/); }); }); describe("plan() banlist (end-to-end)", () => { - it("throws on webm and does not create the planDir", async () => { - const planDir = join(runRoot, "plandir-webm-bans"); - // Don't pre-create planDir — plan() shouldn't create it on the throw path. - let caught: unknown; - try { - await plan( - projectDir, - { - format: "webm" as DistributedRenderConfig["format"], - fps: 30, - width: 320, - height: 240, - }, - planDir, - ); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError); - expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm"); - expect(existsSync(planDir)).toBe(false); - }); - it("throws on HDR mp4 and does not create the planDir", async () => { const planDir = join(runRoot, "plandir-hdr-bans"); + // Don't pre-create planDir — plan() shouldn't create it on the throw path. let caught: unknown; try { await plan( diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 5309cf52f..6c8567ce8 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -401,7 +401,7 @@ export async function renderChunk( const job = buildSyntheticRenderJob({ fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, quality: encoder.quality, - format: plan.dimensions.format as "mp4" | "mov" | "png-sequence", + format: plan.dimensions.format, crf: encoder.crf, bitrate: encoder.bitrate, hdrMode: "force-sdr", @@ -538,16 +538,16 @@ export async function renderChunk( // ── Encode the chunk ── const isPngSequence = plan.dimensions.format === "png-sequence"; outputKind = isPngSequence ? "frame-dir" : "file"; - // For mp4/mov we use the standard preset machinery; the locked encoder - // values come from `meta/encoder.json` and the `lockGopForChunkConcat` - // toggle is the only Phase-2 flag that flips on at this site. - // png-sequence has no encoder, but `runEncodeStage` still reads - // `preset.quality` for bookkeeping (it never reaches ffmpeg on the - // pngseq branch). Fall back to the mp4 preset shape — same trick - // `renderOrchestrator` plays. + // For mp4 / mov / webm we use the standard preset machinery; the + // locked encoder values come from `meta/encoder.json` and the + // `lockGopForChunkConcat` toggle is the only Phase-2 flag that flips + // on at this site. png-sequence has no encoder, but `runEncodeStage` + // still reads `preset.quality` for bookkeeping (it never reaches + // ffmpeg on the pngseq branch). Fall back to the mp4 preset shape — + // same trick `renderOrchestrator` plays. const presetFormat: "mp4" | "mov" | "webm" = isPngSequence ? "mp4" - : (plan.dimensions.format as "mp4" | "mov"); + : (plan.dimensions.format as "mp4" | "mov" | "webm"); const basePreset = getEncoderPreset(job.config.quality, presetFormat, undefined); const preset = resolvePresetForLockedEncoder(basePreset, encoder.encoder); const effectiveQuality = encoder.crf ?? preset.quality; diff --git a/packages/producer/src/services/render/stages/freezePlan.ts b/packages/producer/src/services/render/stages/freezePlan.ts index 106b648a8..5b516c485 100644 --- a/packages/producer/src/services/render/stages/freezePlan.ts +++ b/packages/producer/src/services/render/stages/freezePlan.ts @@ -36,7 +36,12 @@ export interface LockedRenderConfig { warmupTicks: number; // Encode - encoder: "libx264-software" | "libx265-software" | "prores-software" | "png-sequence"; + encoder: + | "libx264-software" + | "libx265-software" + | "libvpx-vp9-software" + | "prores-software" + | "png-sequence"; /** * Caller-supplied quality enum, persisted so chunk workers can rebuild * the matching `getEncoderPreset(quality, format, …)` instead of diff --git a/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts b/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts index 2e1098bc8..7a1e7f41c 100644 --- a/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts +++ b/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts @@ -304,3 +304,183 @@ describe("webm VP9 concat-copy smoke", () => { expect(nbFrames).toBe(TOTAL_FRAMES); }); }); + +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; + + 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 a transparent background + // and an animated solid rect. `color` lavfi source supports alpha + // via `c=@` syntax. + const frameGen = runFfmpegSync([ + "-hide_banner", + "-y", + "-f", + "lavfi", + "-i", + `testsrc2=s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${TOTAL_FRAMES / FPS}`, + "-vf", + "format=rgba", + "-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), + "-i", + join(alphaFramesDir, "frame_%04d.png"), + "-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) { + 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)}`, + ); + } + 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 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)}`, + ); + } + expect(existsSync(alphaOutputPath)).toBe(true); + expect(statSync(alphaOutputPath).size).toBeGreaterThan(0); + }); + + it("decodes alpha-track WebM cleanly without seam errors", () => { + // The decode-test is the load-bearing assertion. The actual pixel + // format reported by ffprobe depends on the source's alpha + // distribution — an RGBA source whose alpha plane is uniformly + // opaque can be encoded as yuva420p but ffprobe may report it as + // yuv420p if libvpx-vp9 drops the alpha sub-stream as redundant. The + // distributed renderer's real compositions (which have meaningful + // alpha) get the alpha branch end-to-end; this smoke test only + // proves that the closed-GOP encoder args + alpha-pixel-format flag + // combination doesn't break concat-copy. + const decodeResult = runFfmpegSync([ + "-hide_banner", + "-v", + "error", + "-i", + alphaOutputPath, + "-f", + "null", + "-", + ]); + if (decodeResult.exitCode !== 0 || decodeResult.stderr.length > 0) { + throw new Error( + `[alpha smoke decode-test] failed (exit ${decodeResult.exitCode}). ` + + `Failure fingerprint: ${decodeResult.stderr.slice(-1000) || "(no stderr)"}`, + ); + } + + const probeResult = runFfprobeSync([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,pix_fmt", + "-of", + "default=noprint_wrappers=1", + alphaOutputPath, + ]); + expect(probeResult.exitCode).toBe(0); + expect(probeResult.stdout).toMatch(/codec_name=vp9/); + // Accept either yuv420p or yuva420p — see the comment above the + // decode-test for why libvpx-vp9 may downgrade uniformly-opaque + // alpha to yuv420p. + expect(probeResult.stdout).toMatch(/pix_fmt=yuva?420p/); + }); +}); From a4ed148ce7460795fe4e08b13cb7eeadbdcfccc9 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 19 May 2026 05:18:53 +0000 Subject: [PATCH 2/3] fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review feedback from Miguel and Vai on #951 caught a real bug: `plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan stage froze `forceScreenshot: false` into the `LockedRenderConfig` even though distributed webm uses `yuva420p`. Every chunk worker captured opaque RGB via BeginFrame (which doesn't preserve alpha on Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha that the encoder then dropped — producing un-keyable webm. Two changes: 1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the in-process renderer's logic at `renderOrchestrator.ts:1469` (`const needsAlpha = isWebm || isMov || isPngSequence`); the two sites must stay in sync since the distributed pipeline's PSNR regression compares against the in-process baseline. 2. **Smoke test (yuva420p describe)**: source frames now use a real alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of `testsrc2 + format=rgba` which was uniformly opaque. The decode- pix_fmt assertion is dropped (ffprobe reports `yuv420p` for VP9-with-alpha because the alpha lives in a Matroska `BlockAdditional` sidecar) and replaced with two stronger checks: - `TAG:ALPHA_MODE=1` is present on the stream — proves the encoder was actually configured for alpha - alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba -vf extractplanes=a,signalstats` — proves the alpha sub-stream round-trips through concat-copy with spatially-varying content, not uniform/dropped alpha - decode-test gate is now exit-code-only (was `exitCode || stderr` which would flake on chatty ffmpeg `-v error` builds emitting non-fatal DTS/container notes) These checks would have caught the `needsAlpha` bug before review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../producer/src/services/distributed/plan.ts | 15 ++- .../_smoke/webm-concat-copy.test.ts | 94 ++++++++++++++----- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 21cc78636..fff15a707 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -653,10 +653,17 @@ export async function plan( // move the contents over once the staged work completes. const finalCompiledDir = join(planDir, "compiled"); - // mov + png-sequence carry alpha — flip force-screenshot so compileStage - // takes the alpha-aware capture path (BeginFrame doesn't preserve alpha - // on Linux headless-shell). - const needsAlpha = config.format === "png-sequence" || config.format === "mov"; + // webm + mov + png-sequence carry alpha — 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 + // webm output preserves the same alpha plane the in-process baseline + // does. Omitting webm here silently freezes `forceScreenshot: false` + // 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"; // ── Compile ── const compileResult = await runCompileStage({ diff --git a/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts b/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts index 7a1e7f41c..be295aa30 100644 --- a/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts +++ b/packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts @@ -330,9 +330,13 @@ describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => { alphaConcatListPath = join(alphaRoot, "concat-list.txt"); alphaOutputPath = join(alphaRoot, "output.webm"); - // For alpha frames, generate RGBA PNGs with a transparent background - // and an animated solid rect. `color` lavfi source supports alpha - // via `c=@` syntax. + // 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", @@ -341,7 +345,7 @@ describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => { "-i", `testsrc2=s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${TOTAL_FRAMES / FPS}`, "-vf", - "format=rgba", + "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"), @@ -439,15 +443,6 @@ describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => { }); it("decodes alpha-track WebM cleanly without seam errors", () => { - // The decode-test is the load-bearing assertion. The actual pixel - // format reported by ffprobe depends on the source's alpha - // distribution — an RGBA source whose alpha plane is uniformly - // opaque can be encoded as yuva420p but ffprobe may report it as - // yuv420p if libvpx-vp9 drops the alpha sub-stream as redundant. The - // distributed renderer's real compositions (which have meaningful - // alpha) get the alpha branch end-to-end; this smoke test only - // proves that the closed-GOP encoder args + alpha-pixel-format flag - // combination doesn't break concat-copy. const decodeResult = runFfmpegSync([ "-hide_banner", "-v", @@ -458,7 +453,12 @@ describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => { "null", "-", ]); - if (decodeResult.exitCode !== 0 || decodeResult.stderr.length > 0) { + // 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)"}`, @@ -470,17 +470,67 @@ describe("webm VP9 concat-copy smoke (yuva420p alpha)", () => { "error", "-select_streams", "v:0", - "-show_entries", - "stream=codec_name,pix_fmt", - "-of", - "default=noprint_wrappers=1", + "-show_streams", alphaOutputPath, ]); expect(probeResult.exitCode).toBe(0); expect(probeResult.stdout).toMatch(/codec_name=vp9/); - // Accept either yuv420p or yuva420p — see the comment above the - // decode-test for why libvpx-vp9 may downgrade uniformly-opaque - // alpha to yuv420p. - expect(probeResult.stdout).toMatch(/pix_fmt=yuva?420p/); + // 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/); + }); + + 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)}`, + ); + } + 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); }); }); From 7f8a332f3528c62e9cc28a880ba83432a660abd1 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 19 May 2026 06:03:06 +0000 Subject: [PATCH 3/3] fix(aws-lambda): widen narrow format types to include webm CI on PR #951 was failing at typecheck/build because the producer's `DistributedRenderConfig.format` widened to include webm in this PR but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"` type literals in `events.ts`, `handler.ts`, and `validateConfig.ts` hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now including webm) into a parameter typed against the narrow union, producing TS2345. This widening originally landed in PR #952 (test fixture PR) but needs to be atomic with the producer's widening here to keep each PR independently typecheck-clean. Also refactor `formatExtension` from a switch dispatch to a `Record` lookup. Adding the webm case tipped the switch's CRAP to the 30.0 fallow threshold; the lookup table drops cyclomatic from 5 to 1 with the same compile-time exhaustiveness guarantee (TS errors on missing entries when `DistributedFormat` adds a new format). The runtime `_exhaustive: never` throw was only protecting against a string slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already gates untrusted input at the SDK boundary. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/aws-lambda/src/events.ts | 6 ++--- packages/aws-lambda/src/formatExtension.ts | 27 ++++++++++--------- packages/aws-lambda/src/handler.ts | 2 +- .../aws-lambda/src/sdk/validateConfig.test.ts | 2 +- packages/aws-lambda/src/sdk/validateConfig.ts | 2 +- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts index 3f61f4f8b..b8b50a9ad 100644 --- a/packages/aws-lambda/src/events.ts +++ b/packages/aws-lambda/src/events.ts @@ -65,7 +65,7 @@ export interface RenderChunkEvent { /** S3 URI prefix where the chunk output should be uploaded (`s3://bucket/{prefix}/`). */ ChunkOutputS3Prefix: string; /** Output container format from the plan's encoder.json; drives file vs frame-dir handling. */ - Format: "mp4" | "mov" | "png-sequence"; + Format: "mp4" | "mov" | "png-sequence" | "webm"; } /** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */ @@ -80,7 +80,7 @@ export interface AssembleEvent { /** Final output S3 URI (`s3://bucket/key.mp4`). */ OutputS3Uri: string; /** Output container format; drives file vs frame-dir handling. */ - Format: "mp4" | "mov" | "png-sequence"; + Format: "mp4" | "mov" | "png-sequence" | "webm"; } /** @@ -106,7 +106,7 @@ export interface PlanLambdaResult { Fps: 24 | 30 | 60; Width: number; Height: number; - Format: "mp4" | "mov" | "png-sequence"; + Format: "mp4" | "mov" | "png-sequence" | "webm"; HasAudio: boolean; AudioS3Uri: string | null; FfmpegVersion: string; diff --git a/packages/aws-lambda/src/formatExtension.ts b/packages/aws-lambda/src/formatExtension.ts index 61368daa3..b33ef4352 100644 --- a/packages/aws-lambda/src/formatExtension.ts +++ b/packages/aws-lambda/src/formatExtension.ts @@ -6,19 +6,20 @@ * looks like vs a png-sequence. */ -export type DistributedFormat = "mp4" | "mov" | "png-sequence"; +export type DistributedFormat = "mp4" | "mov" | "png-sequence" | "webm"; + +// Closed-enum lookup table. TS enforces exhaustiveness via the +// `Record` annotation — adding a format to +// `DistributedFormat` without adding the matching key here fails to +// typecheck, which is the same exhaustiveness guarantee a switch + +// `_exhaustive: never` arm provides but at lower complexity. +const FORMAT_EXTENSIONS: Record = { + mp4: ".mp4", + mov: ".mov", + webm: ".webm", + "png-sequence": "", +}; export function formatExtension(format: DistributedFormat): string { - switch (format) { - case "mp4": - return ".mp4"; - case "mov": - return ".mov"; - case "png-sequence": - return ""; - default: { - const _exhaustive: never = format; - throw new Error(`[formatExtension] unsupported format: ${_exhaustive as string}`); - } - } + return FORMAT_EXTENSIONS[format]; } diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index 85e5ff16f..78a2a3b77 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -433,7 +433,7 @@ async function downloadChunkObjects( s3: S3Client, uris: string[], workDir: string, - format: "mp4" | "mov" | "png-sequence", + format: "mp4" | "mov" | "png-sequence" | "webm", ): Promise { const chunksDir = join(workDir, "chunks"); mkdirSync(chunksDir, { recursive: true }); diff --git a/packages/aws-lambda/src/sdk/validateConfig.test.ts b/packages/aws-lambda/src/sdk/validateConfig.test.ts index 7967f6a81..19a4f35a4 100644 --- a/packages/aws-lambda/src/sdk/validateConfig.test.ts +++ b/packages/aws-lambda/src/sdk/validateConfig.test.ts @@ -54,7 +54,7 @@ describe("validateDistributedRenderConfig", () => { "unsupported format", { ...VALID, - format: "webm", + format: "gif", } as unknown as SerializableDistributedRenderConfig, "config.format", ], diff --git a/packages/aws-lambda/src/sdk/validateConfig.ts b/packages/aws-lambda/src/sdk/validateConfig.ts index 51e085293..f959bcab1 100644 --- a/packages/aws-lambda/src/sdk/validateConfig.ts +++ b/packages/aws-lambda/src/sdk/validateConfig.ts @@ -31,7 +31,7 @@ export class InvalidConfigError extends Error { } const ALLOWED_FPS = [24, 30, 60] as const; -const ALLOWED_FORMATS = ["mp4", "mov", "png-sequence"] as const; +const ALLOWED_FORMATS = ["mp4", "mov", "png-sequence", "webm"] as const; const ALLOWED_CODECS = ["h264", "h265"] as const; const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const; const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const;