Skip to content

Commit fd43c14

Browse files
jrusso1020claude
andcommitted
feat(producer): enable webm in distributed mode via concat-copy
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) <noreply@anthropic.com>
1 parent 4573e4e commit fd43c14

7 files changed

Lines changed: 323 additions & 86 deletions

File tree

packages/producer/src/services/distributed/assemble.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22
* Activity C of the distributed render pipeline.
33
*
44
* `assemble(planDir, chunkPaths, audioPath, outputPath)` stitches per-chunk
5-
* outputs into the final deliverable. For mp4/mov this is `ffmpeg -f concat
6-
* -c copy` (free of re-encode loss because every chunk's first frame is an
7-
* IDR keyframe — the chunk encoder sets `lockGopForChunkConcat` to
8-
* enforce this). For png-sequence chunks (each chunk is a directory of
5+
* outputs into the final deliverable. For mp4 / mov / webm this is
6+
* `ffmpeg -f concat -c copy` (free of re-encode loss because every
7+
* chunk's first frame is an IDR keyframe — the chunk encoder sets
8+
* `lockGopForChunkConcat` to enforce this, which for libvpx-vp9 also
9+
* disables alt-ref frames so concat seams remain independently
10+
* decodable). For png-sequence chunks (each chunk is a directory of
911
* frames) this is a straight directory merge with global re-numbering.
1012
*
11-
* Mux + faststart for mp4/mov go through the engine's `muxVideoWithAudio`
12-
* + `applyFaststart` helpers — same path the in-process renderer uses; we
13-
* just feed concat output rather than streaming-encoder output. Audio
14-
* length is pad-or-trimmed to `frameCount / fps` via
13+
* Mux + faststart for mp4 / mov / webm go through the engine's
14+
* `muxVideoWithAudio` + `applyFaststart` helpers — same path the
15+
* in-process renderer uses; we just feed concat output rather than
16+
* streaming-encoder output. (Faststart is a no-op for webm and mov —
17+
* applyFaststart copies the input verbatim.) Audio length is
18+
* pad-or-trimmed to `frameCount / fps` via
1519
* `padOrTrimAudioToVideoFrameCount` so the mux step doesn't introduce
1620
* sub-millisecond drift at the end of long renders.
1721
*
@@ -116,7 +120,7 @@ export async function assemble(
116120
return mergePngFrameDirs(chunkPaths, outputPath, plan.totalFrames, audioPath, start);
117121
}
118122

119-
// ── 2b. mp4 / mov: concat-copy then mux + faststart ────────────────────
123+
// ── 2b. mp4 / mov / webm: concat-copy then mux + faststart ────────────
120124
if (!existsSync(dirname(outputPath))) {
121125
mkdirSync(dirname(outputPath), { recursive: true });
122126
}

packages/producer/src/services/distributed/plan.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,58 @@ describe("plan() — codec knob", () => {
460460
expect((caught as Error).message).toMatch(/codec must be "h264" or "h265"/);
461461
});
462462
});
463+
464+
describe("plan() — webm format (distributed VP9)", () => {
465+
const TIMEOUT_MS = 30_000;
466+
467+
it(
468+
'maps `format: "webm"` to libvpx-vp9-software + yuva420p',
469+
async () => {
470+
// Webm is distributed-supported via closed-GOP concat-copy (PR 8.1
471+
// proved the contract; this test pins the plan-time encoder choice).
472+
// yuva420p preserves the format's reason for existing — alpha video
473+
// for web playback over colored backgrounds.
474+
const planDir = join(runRoot, "plan-webm-vp9");
475+
mkdirSync(planDir, { recursive: true });
476+
const result = await plan(
477+
projectDir,
478+
{ fps: 30, width: 320, height: 240, format: "webm" },
479+
planDir,
480+
);
481+
expect(result.format).toBe("webm");
482+
483+
const encoder = JSON.parse(
484+
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
485+
) as Record<string, unknown>;
486+
expect(encoder.encoder).toBe("libvpx-vp9-software");
487+
expect(encoder.pixelFormat).toBe("yuva420p");
488+
// Closed-GOP must be on so concat-copy at assemble time works.
489+
// gopSize equals the chunkSize so every chunk's first frame is a
490+
// keyframe with no alt-ref references reaching back across seams.
491+
expect(encoder.closedGop).toBe(true);
492+
expect(encoder.gopSize).toBe(encoder.chunkSize);
493+
},
494+
TIMEOUT_MS,
495+
);
496+
497+
it("rejects `codec` with format=webm", async () => {
498+
// webm is always libvpx-vp9 — same shape as mov (always ProRes 4444).
499+
// A JS caller building config from JSON who passes `codec: "vp8"` or
500+
// `codec: "av1"` must hit a typed error, not silently encode VP9.
501+
const planDir = join(runRoot, "plan-webm-bad-codec");
502+
mkdirSync(planDir, { recursive: true });
503+
let caught: unknown;
504+
try {
505+
await plan(
506+
projectDir,
507+
// @ts-expect-error — runtime check is the test's purpose.
508+
{ fps: 30, width: 320, height: 240, format: "webm", codec: "vp8" },
509+
planDir,
510+
);
511+
} catch (err) {
512+
caught = err;
513+
}
514+
expect(caught).toBeInstanceOf(Error);
515+
expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/);
516+
});
517+
});

packages/producer/src/services/distributed/plan.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,19 @@ export interface DistributedRenderConfig {
7474
width: number;
7575
height: number;
7676
/**
77-
* Output container format. webm and HDR mp4 are not supported in
78-
* distributed mode — `plan()` refuses them up front with a typed
77+
* Output container format. HDR mp4 is not supported in distributed
78+
* mode — `plan()` refuses it up front with a typed
7979
* `FormatNotSupportedInDistributedError`. The in-process renderer
80-
* supports both.
80+
* supports it.
81+
*
82+
* `"webm"` (VP9 + Opus) is distributed-supported via closed-GOP
83+
* concat-copy: `lockGopForChunkConcat=true` forces a keyframe at every
84+
* chunk boundary and disables libvpx-vp9's alt-ref frames so chunk
85+
* files stitch losslessly. See `chunkEncoder.ts` for the VP9 args and
86+
* `tests/distributed/_smoke/webm-concat-copy.test.ts` for the gating
87+
* experiment that proved the contract.
8188
*/
82-
format: "mp4" | "mov" | "png-sequence";
89+
format: "mp4" | "mov" | "png-sequence" | "webm";
8390
/**
8491
* Codec selection for `format: "mp4"`. `"h264"` (the default) → libx264 +
8592
* yuv420p; `"h265"` → libx265 + yuv420p with closed-GOP keyint params
@@ -169,7 +176,7 @@ export interface PlanResult {
169176
fps: 24 | 30 | 60;
170177
width: number;
171178
height: number;
172-
format: "mp4" | "mov" | "png-sequence";
179+
format: "mp4" | "mov" | "png-sequence" | "webm";
173180
ffmpegVersion: string;
174181
producerVersion: string;
175182
}
@@ -247,22 +254,25 @@ export class PlanTooLargeError extends Error {
247254

248255
/**
249256
* Non-retryable error code raised when `plan()` is asked for an output
250-
* format that distributed mode doesn't support (webm, HDR mp4). The same
251-
* config would fail on every retry, so the failure must not auto-retry.
257+
* format that distributed mode doesn't support (currently: HDR mp4). The
258+
* same config would fail on every retry, so the failure must not
259+
* auto-retry.
252260
*/
253261
export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED";
254262

255263
/**
256264
* Typed error raised by `plan()` for outputs that distributed mode
257265
* refuses to ship.
258266
*
259-
* - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9
260-
* builds, and the chunked pipeline can't guarantee bit-identical
261-
* concat output across worker versions.
262267
* - mp4 + HDR (PQ / HLG) — chunked HDR pre-extract + HDR signaling
263268
* re-apply on the assembled file is not implemented yet.
264269
*
265-
* The in-process renderer (`executeRenderJob`) handles both natively.
270+
* The in-process renderer (`executeRenderJob`) handles it natively.
271+
*
272+
* WebM was previously refused here; v0.7+ supports it via closed-GOP
273+
* concat-copy. See {@link DistributedRenderConfig.format} for the
274+
* supported set and {@link rejectUnsupportedDistributedFormat} for the
275+
* gate.
266276
*/
267277
export class FormatNotSupportedInDistributedError extends Error {
268278
readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
@@ -272,8 +282,8 @@ export class FormatNotSupportedInDistributedError extends Error {
272282
super(
273283
`[plan] format ${JSON.stringify(format)} is not supported in distributed mode: ${reason}. ` +
274284
`Render with the in-process renderer (\`executeRenderJob\`) — it has full format ` +
275-
`support — or pick a distributed-supported format: mp4 SDR, mov ProRes 4444, or ` +
276-
`png-sequence.`,
285+
`support — or pick a distributed-supported format: mp4 SDR, mov ProRes 4444, ` +
286+
`png-sequence, or webm VP9.`,
277287
);
278288
this.name = "FormatNotSupportedInDistributedError";
279289
this.format = format;
@@ -289,7 +299,9 @@ function formatBytes(bytes: number): string {
289299
}
290300

291301
/**
292-
* Reject formats the distributed pipeline cannot ship (webm + HDR mp4).
302+
* Reject formats the distributed pipeline cannot ship (HDR mp4 only —
303+
* webm is supported as of v0.7 via closed-GOP concat-copy).
304+
*
293305
* Throws {@link FormatNotSupportedInDistributedError} with a message
294306
* naming the rejected format. Runs at the very top of `plan()` so a
295307
* banned input never produces a partial planDir.
@@ -302,17 +314,6 @@ function formatBytes(bytes: number): string {
302314
export function rejectUnsupportedDistributedFormat(
303315
config: Pick<DistributedRenderConfig, "format" | "hdrMode">,
304316
): void {
305-
// The TypeScript type for `DistributedRenderConfig.format` already
306-
// excludes webm, but a JS caller (or a caller that built the config
307-
// dynamically from JSON) can still pass it. Belt-and-suspenders runtime
308-
// check at the gate.
309-
if ((config.format as string) === "webm") {
310-
throw new FormatNotSupportedInDistributedError(
311-
"webm",
312-
"VP9 + matroska concat-copy is fragile across libvpx-vp9 builds, so chunked output " +
313-
"can't be guaranteed byte-identical across workers",
314-
);
315-
}
316317
if ((config.hdrMode as string) === "force-hdr") {
317318
throw new FormatNotSupportedInDistributedError(
318319
"mp4-hdr",
@@ -513,7 +514,8 @@ function buildLockedRenderConfig(input: {
513514
/**
514515
* Resolve the encoder + pixel-format + preset triple for a distributed
515516
* render. Distributed mode is SDR-only: H.264 or H.265 8-bit for mp4,
516-
* ProRes 4444 for mov, raw RGBA for png-sequence.
517+
* libvpx-vp9 + yuva420p (alpha) for webm, ProRes 4444 for mov, raw RGBA
518+
* for png-sequence.
517519
*
518520
* `config.codec` is consulted only when `config.format === "mp4"`. Passing
519521
* `codec` with a non-mp4 format throws at plan time — surfaces the
@@ -547,12 +549,21 @@ function resolveEncoderTriple(config: DistributedRenderConfig): {
547549
throw new Error(
548550
`[plan] DistributedRenderConfig.codec is only valid for format="mp4"; received ` +
549551
`codec=${JSON.stringify(config.codec)} with format=${JSON.stringify(config.format)}. ` +
550-
`Omit codec for non-mp4 formats — mov is always ProRes 4444 and png-sequence has no encoder.`,
552+
`Omit codec for non-mp4 formats — mov is always ProRes 4444, webm is always ` +
553+
`libvpx-vp9, and png-sequence has no encoder.`,
551554
);
552555
}
553556
if (config.format === "mov") {
554557
return { encoder: "prores-software", pixelFormat: "yuva444p10le", preset: "4444" };
555558
}
559+
if (config.format === "webm") {
560+
// webm distributes via closed-GOP libvpx-vp9 + concat-copy. yuva420p
561+
// matches the in-process renderer's webm pixel format (alpha-capable
562+
// — the format's main reason for existing). `getEncoderPreset` in
563+
// the engine returns "good" for non-draft quality tiers; that becomes
564+
// libvpx-vp9's `-deadline good` at encode time.
565+
return { encoder: "libvpx-vp9-software", pixelFormat: "yuva420p", preset: "good" };
566+
}
556567
return { encoder: "png-sequence", pixelFormat: "rgba", preset: "lossless" };
557568
}
558569

packages/producer/src/services/distributed/planFormatBanlist.test.ts

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
/**
22
* Unit tests for the distributed format banlist.
33
*
4-
* Two formats `plan()` refuses up front:
5-
* - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9 builds.
4+
* `plan()` refuses one configuration up front:
65
* - mp4 + HDR (`hdrMode === "force-hdr"`) — chunked HDR pre-extract +
76
* HDR signaling re-apply on the assembled file is not implemented.
87
*
98
* The banlist must trip BEFORE any other work runs (file server, browser,
109
* ffprobe) — otherwise a banned config can leak a partial planDir on disk.
11-
* Each case asserts `existsSync(planDir)` is `false` after the throw to
12-
* pin the early-exit contract.
10+
* The HDR case asserts `existsSync(planDir)` is `false` after the throw
11+
* to pin the early-exit contract.
12+
*
13+
* WebM was previously refused here; v0.7+ supports it via closed-GOP
14+
* concat-copy. The "accepts webm" tests below pin the contract that
15+
* `rejectUnsupportedDistributedFormat` no longer trips on webm.
1316
*/
1417

1518
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
@@ -44,24 +47,28 @@ afterAll(() => {
4447
});
4548

4649
describe("rejectUnsupportedDistributedFormat (pure)", () => {
47-
it("accepts the v1-supported formats (mp4 / mov / png-sequence)", () => {
50+
it("accepts the v1-supported formats (mp4 / mov / png-sequence / webm)", () => {
4851
expect(() => rejectUnsupportedDistributedFormat({ format: "mp4" })).not.toThrow();
4952
expect(() => rejectUnsupportedDistributedFormat({ format: "mov" })).not.toThrow();
5053
expect(() => rejectUnsupportedDistributedFormat({ format: "png-sequence" })).not.toThrow();
54+
expect(() => rejectUnsupportedDistributedFormat({ format: "webm" })).not.toThrow();
5155
expect(() =>
5256
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "auto" }),
5357
).not.toThrow();
5458
expect(() =>
5559
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "force-sdr" }),
5660
).not.toThrow();
61+
expect(() =>
62+
rejectUnsupportedDistributedFormat({ format: "webm", hdrMode: "force-sdr" }),
63+
).not.toThrow();
5764
});
5865

59-
it("rejects webm with FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", () => {
66+
it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => {
6067
let caught: unknown;
6168
try {
62-
// Cast forces the runtime check even though the type narrows webm out.
6369
rejectUnsupportedDistributedFormat({
64-
format: "webm" as DistributedRenderConfig["format"],
70+
format: "mp4",
71+
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
6572
});
6673
} catch (err) {
6774
caught = err;
@@ -70,56 +77,31 @@ describe("rejectUnsupportedDistributedFormat (pure)", () => {
7077
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
7178
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
7279
);
73-
expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm");
74-
expect((caught as Error).message).toMatch(/webm/);
75-
expect((caught as Error).message).toMatch(/in-process|executeRenderJob/);
80+
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
81+
expect((caught as Error).message).toMatch(/HDR/);
7682
});
7783

78-
it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => {
84+
it("rejects HDR + webm combination (HDR is the trip, not webm)", () => {
85+
// Belt-and-suspenders: even when webm is the format, force-hdr must
86+
// still throw — distributed HDR is unimplemented regardless of format.
7987
let caught: unknown;
8088
try {
8189
rejectUnsupportedDistributedFormat({
82-
format: "mp4",
90+
format: "webm",
8391
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
8492
});
8593
} catch (err) {
8694
caught = err;
8795
}
8896
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
89-
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
90-
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
91-
);
9297
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
93-
expect((caught as Error).message).toMatch(/HDR/);
9498
});
9599
});
96100

97101
describe("plan() banlist (end-to-end)", () => {
98-
it("throws on webm and does not create the planDir", async () => {
99-
const planDir = join(runRoot, "plandir-webm-bans");
100-
// Don't pre-create planDir — plan() shouldn't create it on the throw path.
101-
let caught: unknown;
102-
try {
103-
await plan(
104-
projectDir,
105-
{
106-
format: "webm" as DistributedRenderConfig["format"],
107-
fps: 30,
108-
width: 320,
109-
height: 240,
110-
},
111-
planDir,
112-
);
113-
} catch (err) {
114-
caught = err;
115-
}
116-
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
117-
expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm");
118-
expect(existsSync(planDir)).toBe(false);
119-
});
120-
121102
it("throws on HDR mp4 and does not create the planDir", async () => {
122103
const planDir = join(runRoot, "plandir-hdr-bans");
104+
// Don't pre-create planDir — plan() shouldn't create it on the throw path.
123105
let caught: unknown;
124106
try {
125107
await plan(

packages/producer/src/services/distributed/renderChunk.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ export async function renderChunk(
401401
const job = buildSyntheticRenderJob({
402402
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
403403
quality: encoder.quality,
404-
format: plan.dimensions.format as "mp4" | "mov" | "png-sequence",
404+
format: plan.dimensions.format,
405405
crf: encoder.crf,
406406
bitrate: encoder.bitrate,
407407
hdrMode: "force-sdr",
@@ -536,16 +536,16 @@ export async function renderChunk(
536536
// ── Encode the chunk ──
537537
const isPngSequence = plan.dimensions.format === "png-sequence";
538538
outputKind = isPngSequence ? "frame-dir" : "file";
539-
// For mp4/mov we use the standard preset machinery; the locked encoder
540-
// values come from `meta/encoder.json` and the `lockGopForChunkConcat`
541-
// toggle is the only Phase-2 flag that flips on at this site.
542-
// png-sequence has no encoder, but `runEncodeStage` still reads
543-
// `preset.quality` for bookkeeping (it never reaches ffmpeg on the
544-
// pngseq branch). Fall back to the mp4 preset shape — same trick
545-
// `renderOrchestrator` plays.
539+
// For mp4 / mov / webm we use the standard preset machinery; the
540+
// locked encoder values come from `meta/encoder.json` and the
541+
// `lockGopForChunkConcat` toggle is the only Phase-2 flag that flips
542+
// on at this site. png-sequence has no encoder, but `runEncodeStage`
543+
// still reads `preset.quality` for bookkeeping (it never reaches
544+
// ffmpeg on the pngseq branch). Fall back to the mp4 preset shape —
545+
// same trick `renderOrchestrator` plays.
546546
const presetFormat: "mp4" | "mov" | "webm" = isPngSequence
547547
? "mp4"
548-
: (plan.dimensions.format as "mp4" | "mov");
548+
: (plan.dimensions.format as "mp4" | "mov" | "webm");
549549
const basePreset = getEncoderPreset(job.config.quality, presetFormat, undefined);
550550
const preset = resolvePresetForLockedEncoder(basePreset, encoder.encoder);
551551
const effectiveQuality = encoder.crf ?? preset.quality;

0 commit comments

Comments
 (0)