diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 62e2bc503..e077fd951 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -64,7 +64,7 @@ jobs: - shard: shard-2 args: "style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets" - shard: shard-3 - args: "style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr" + args: "style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9" - shard: shard-4 args: "style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr" - shard: shard-5 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..dcda6c002 100644 --- a/packages/aws-lambda/src/sdk/validateConfig.ts +++ b/packages/aws-lambda/src/sdk/validateConfig.ts @@ -12,8 +12,9 @@ * * The check is deliberately narrow — it covers the *shape* errors any * caller could have surfaced with `tsc` if they passed a literal, plus - * the documented `webm`/`force-hdr` rejections from §5.3 of the - * distributed-rendering plan. Anything deeper (font availability, plan + * the `force-hdr` rejection (HDR mp4 isn't supported in distributed + * mode). webm was previously rejected here too; v0.7+ supports it via + * closed-GOP concat-copy. Anything deeper (font availability, plan * size cap, GPU mode at runtime) needs the actual planner. */ @@ -31,7 +32,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; diff --git a/packages/producer/src/regression-harness-distributed.test.ts b/packages/producer/src/regression-harness-distributed.test.ts index a036aba36..6de6de6be 100644 --- a/packages/producer/src/regression-harness-distributed.test.ts +++ b/packages/producer/src/regression-harness-distributed.test.ts @@ -70,12 +70,9 @@ describe("checkDistributedSupport()", () => { } }); - it("rejects format=webm", () => { + it("accepts format=webm (distributed-supported via closed-GOP concat-copy)", () => { const result = checkDistributedSupport({ fps: { num: 30, den: 1 }, format: "webm" }); - expect(result.supported).toBe(false); - if (!result.supported) { - expect(result.reason).toMatch(/webm/); - } + expect(result.supported).toBe(true); }); it("rejects hdr=true", () => { diff --git a/packages/producer/src/regression-harness-distributed.ts b/packages/producer/src/regression-harness-distributed.ts index 449bced29..5283b31f1 100644 --- a/packages/producer/src/regression-harness-distributed.ts +++ b/packages/producer/src/regression-harness-distributed.ts @@ -25,9 +25,9 @@ * capture jitter, so the harness can't use it as a per-test gate. * * Not every fixture can run in distributed-simulated mode. Distributed mode - * refuses webm, HDR mp4, NTSC framerates, and non-{24,30,60} fps at plan - * time. Fixtures that don't meet the constraints are skipped — the harness - * logs the reason and the fixture is treated as "passed (skipped)" in + * refuses HDR mp4, NTSC framerates, and non-{24,30,60} fps at plan time. + * Fixtures that don't meet the constraints are skipped — the harness logs + * the reason and the fixture is treated as "passed (skipped)" in * distributed-simulated mode. */ @@ -67,15 +67,13 @@ export type DistributedSupportResult = { supported: true } | { supported: false; /** * Decide whether a fixture's `renderConfig` is one the distributed pipeline - * can actually run. The four hard gates: + * can actually run. Two hard gates: * * - fps must be `{ num: 24|30|60, den: 1 }`. `DistributedRenderConfig.fps` * accepts only the three integer values, and rationals like * `{ num: 30000, den: 1001 }` (NTSC) trip the type system at the call * site. We surface this gate in code rather than only in TS so the * harness can skip the fixture cleanly instead of throwing. - * - format must not be `webm`. `plan()` refuses webm with - * `FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED`. * - hdr must not be `true`. Distributed mode is SDR-only at v1. * * Callers that want the structured reason can read it off the returned @@ -99,13 +97,6 @@ export function checkDistributedSupport(renderConfig: { reason: `fps ${fpsNum} not in {24, 30, 60} (DistributedRenderConfig.fps is a closed set)`, }; } - const format = renderConfig.format ?? "mp4"; - if (format === "webm") { - return { - supported: false, - reason: "format=webm refused in distributed mode (VP9+matroska concat-copy is unstable)", - }; - } if (renderConfig.hdr === true) { return { supported: false, @@ -129,7 +120,7 @@ export interface RunDistributedSimulatedInput { renderedOutputPath: string; /** From the fixture's renderConfig — must pass `checkDistributedSupport`. */ fps: 24 | 30 | 60; - format: "mp4" | "mov" | "png-sequence"; + format: "mp4" | "mov" | "png-sequence" | "webm"; /** * Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to * exercise the libx265 closed-GOP path. Ignored for non-mp4 formats — diff --git a/packages/producer/src/regression-harness-lambda-local-types.ts b/packages/producer/src/regression-harness-lambda-local-types.ts index bb021951a..54d11c25f 100644 --- a/packages/producer/src/regression-harness-lambda-local-types.ts +++ b/packages/producer/src/regression-harness-lambda-local-types.ts @@ -26,7 +26,7 @@ export interface RunLambdaLocalInput { */ width: number; height: number; - format: "mp4" | "mov" | "png-sequence"; + format: "mp4" | "mov" | "png-sequence" | "webm"; codec?: "h264" | "h265"; chunkSize?: number; maxParallelChunks?: number; diff --git a/packages/producer/src/regression-harness.ts b/packages/producer/src/regression-harness.ts index b3cef543a..19d5b8f3e 100644 --- a/packages/producer/src/regression-harness.ts +++ b/packages/producer/src/regression-harness.ts @@ -94,8 +94,8 @@ type TestMetadata = { * single video file — the harness branches its comparison logic * accordingly (per-frame byte equality instead of PSNR). `"mov"` and * `"webm"` are encoded video containers that share the PSNR path with - * `"mp4"`. `"webm"` is rejected by the distributed pipeline at plan - * time; the in-process renderer accepts it. + * `"mp4"`. Distributed mode supports all four — webm goes through + * libvpx-vp9 with closed-GOP concat-copy. */ format?: "mp4" | "webm" | "mov" | "png-sequence"; /** @@ -163,7 +163,7 @@ type TestResult = { passed: boolean; /** * Set when `--mode=distributed-simulated` skips a fixture that the - * distributed pipeline can't run (webm, HDR, NTSC fps, fps∉{24,30,60}). + * distributed pipeline can't run (HDR, NTSC fps, fps∉{24,30,60}). * `passed` is `true` for skipped fixtures — skipping is a clean outcome, * not a failure — but the summary distinguishes them. */ @@ -939,19 +939,16 @@ async function runTestSuite( result.skipped = { reason: support.reason }; return result; } - // `checkDistributedSupport` already narrowed fps to {24,30,60} and - // rejected webm; the cast surfaces that guarantee to TS. + // `checkDistributedSupport` already narrowed fps to {24,30,60}; the + // cast surfaces that guarantee to TS. webm is now distributed- + // supported via closed-GOP concat-copy, so the format passes through. const fpsNum = suite.meta.renderConfig.fps.num as 24 | 30 | 60; const distributedInput = { projectDir: tempSrcDir, tempRoot, renderedOutputPath, fps: fpsNum, - // `runDistributedSimulatedRender` / `runLambdaLocalRender`'s - // `format` parameter accepts the distributed-supported set; - // the harness type allows `"webm"` too but - // `checkDistributedSupport` rejected that above. Narrow. - format: outputFormat as "mp4" | "mov" | "png-sequence", + format: outputFormat, codec: suite.meta.renderConfig.codec, chunkSize: suite.meta.renderConfig.chunkSize, maxParallelChunks: suite.meta.renderConfig.maxParallelChunks, 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..fff15a707 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" }; } @@ -642,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/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..be295aa30 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,233 @@ 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 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), + "-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", () => { + 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)"}`, + ); + } + + 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/); + }); + + 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); + }); +}); diff --git a/packages/producer/tests/distributed/webm-vp9/meta.json b/packages/producer/tests/distributed/webm-vp9/meta.json new file mode 100644 index 000000000..5f67e58f5 --- /dev/null +++ b/packages/producer/tests/distributed/webm-vp9/meta.json @@ -0,0 +1,17 @@ +{ + "name": "Distributed: webm VP9", + "description": "60-frame composition (2s @ 30fps) with text and a small rotating SVG icon, rendered to webm (VP9 + yuva420p). renderConfig.format=webm routes the distributed pipeline through libvpx-vp9 with closed-GOP keyint params (-g N -keyint_min N -auto-alt-ref 0 -cpu-used 2) so per-chunk VP9 output can be losslessly stitched with `ffmpeg -f concat -c copy`. The in-process baseline renders to webm too (codec=vp9, pixelFormat=yuva420p), so the harness's PSNR comparison measures 'libvpx-vp9 chunked + concat' against 'libvpx-vp9 single-pass'. Closed-GOP forces more keyframes than open-GOP, which inflates per-chunk bitrate at constant CRF; PSNR threshold is set at 30 dB to absorb the resulting cross-mode drift without masking gross regressions.", + "tags": ["distributed", "webm", "vp9", "sdr"], + + "minPsnr": 30, + "maxFrameFailures": 0, + + "minAudioCorrelation": 0.9, + "maxAudioLagWindows": 120, + + "renderConfig": { + "fps": 30, + "format": "webm", + "chunkSize": 15 + } +} diff --git a/packages/producer/tests/distributed/webm-vp9/output/compiled.html b/packages/producer/tests/distributed/webm-vp9/output/compiled.html new file mode 100644 index 000000000..c2f95979e --- /dev/null +++ b/packages/producer/tests/distributed/webm-vp9/output/compiled.html @@ -0,0 +1,165 @@ + + + + + + webm VP9 distributed fixture + + + + +
+
+
CHUNK
+
PHASE ONE
+
+
+
CHUNK
+
PHASE TWO
+
+ + + + + + +
+ + + + diff --git a/packages/producer/tests/distributed/webm-vp9/output/output.webm b/packages/producer/tests/distributed/webm-vp9/output/output.webm new file mode 100644 index 000000000..d541643af --- /dev/null +++ b/packages/producer/tests/distributed/webm-vp9/output/output.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b98efde6993524eaec419b2a7d3c37f33dcc84602bbd89cfcb1eea41cc125506 +size 78674 diff --git a/packages/producer/tests/distributed/webm-vp9/src/index.html b/packages/producer/tests/distributed/webm-vp9/src/index.html new file mode 100644 index 000000000..e73c0db59 --- /dev/null +++ b/packages/producer/tests/distributed/webm-vp9/src/index.html @@ -0,0 +1,131 @@ + + + + + + webm VP9 distributed fixture + + + + +
+
+
CHUNK
+
PHASE ONE
+
+
+
CHUNK
+
PHASE TWO
+
+ + + + + + +
+ + + +