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
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
2 changes: 1 addition & 1 deletion packages/aws-lambda/src/sdk/validateConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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"/);
});
});
80 changes: 49 additions & 31 deletions packages/producer/src/services/distributed/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -247,22 +254,25 @@ 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";

/**
* 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;
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -302,17 +314,6 @@ function formatBytes(bytes: number): string {
export function rejectUnsupportedDistributedFormat(
config: Pick<DistributedRenderConfig, "format" | "hdrMode">,
): 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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" };
}

Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading