Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/aws-lambda/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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";
}

/**
Expand All @@ -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;
Expand Down
27 changes: 14 additions & 13 deletions packages/aws-lambda/src/formatExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DistributedFormat, string>` 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<DistributedFormat, string> = {
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];
}
2 changes: 1 addition & 1 deletion packages/aws-lambda/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
const chunksDir = join(workDir, "chunks");
mkdirSync(chunksDir, { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-lambda/src/sdk/validateConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("validateDistributedRenderConfig", () => {
"unsupported format",
{
...VALID,
format: "webm",
format: "gif",
} as unknown as SerializableDistributedRenderConfig,
"config.format",
],
Expand Down
7 changes: 4 additions & 3 deletions packages/aws-lambda/src/sdk/validateConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand All @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions packages/producer/src/regression-harness-distributed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 5 additions & 14 deletions packages/producer/src/regression-harness-distributed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 7 additions & 10 deletions packages/producer/src/regression-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/**
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 13 additions & 9 deletions packages/producer/src/services/distributed/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 });
}
Expand Down
55 changes: 55 additions & 0 deletions packages/producer/src/services/distributed/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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"/);
});
});
Loading
Loading