Skip to content

Commit 21f5066

Browse files
jrusso1020claude
andauthored
feat(producer): enable webm in distributed mode via concat-copy (#951)
* 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> * fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke PR review feedback from Miguel and Vai on #951 caught a real bug: `plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan stage froze `forceScreenshot: false` into the `LockedRenderConfig` even though distributed webm uses `yuva420p`. Every chunk worker captured opaque RGB via BeginFrame (which doesn't preserve alpha on Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha that the encoder then dropped — producing un-keyable webm. Two changes: 1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the in-process renderer's logic at `renderOrchestrator.ts:1469` (`const needsAlpha = isWebm || isMov || isPngSequence`); the two sites must stay in sync since the distributed pipeline's PSNR regression compares against the in-process baseline. 2. **Smoke test (yuva420p describe)**: source frames now use a real alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of `testsrc2 + format=rgba` which was uniformly opaque. The decode- pix_fmt assertion is dropped (ffprobe reports `yuv420p` for VP9-with-alpha because the alpha lives in a Matroska `BlockAdditional` sidecar) and replaced with two stronger checks: - `TAG:ALPHA_MODE=1` is present on the stream — proves the encoder was actually configured for alpha - alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba -vf extractplanes=a,signalstats` — proves the alpha sub-stream round-trips through concat-copy with spatially-varying content, not uniform/dropped alpha - decode-test gate is now exit-code-only (was `exitCode || stderr` which would flake on chatty ffmpeg `-v error` builds emitting non-fatal DTS/container notes) These checks would have caught the `needsAlpha` bug before review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(aws-lambda): widen narrow format types to include webm CI on PR #951 was failing at typecheck/build because the producer's `DistributedRenderConfig.format` widened to include webm in this PR but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"` type literals in `events.ts`, `handler.ts`, and `validateConfig.ts` hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now including webm) into a parameter typed against the narrow union, producing TS2345. This widening originally landed in PR #952 (test fixture PR) but needs to be atomic with the producer's widening here to keep each PR independently typecheck-clean. Also refactor `formatExtension` from a switch dispatch to a `Record<DistributedFormat, string>` lookup. Adding the webm case tipped the switch's CRAP to the 30.0 fallow threshold; the lookup table drops cyclomatic from 5 to 1 with the same compile-time exhaustiveness guarantee (TS errors on missing entries when `DistributedFormat` adds a new format). The runtime `_exhaustive: never` throw was only protecting against a string slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already gates untrusted input at the SDK boundary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07de7e6 commit 21f5066

12 files changed

Lines changed: 404 additions & 109 deletions

File tree

packages/aws-lambda/src/events.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export interface RenderChunkEvent {
6565
/** S3 URI prefix where the chunk output should be uploaded (`s3://bucket/{prefix}/`). */
6666
ChunkOutputS3Prefix: string;
6767
/** Output container format from the plan's encoder.json; drives file vs frame-dir handling. */
68-
Format: "mp4" | "mov" | "png-sequence";
68+
Format: "mp4" | "mov" | "png-sequence" | "webm";
6969
}
7070

7171
/** Activity C: fetch planDir + all chunks + audio, assemble, upload final. */
@@ -80,7 +80,7 @@ export interface AssembleEvent {
8080
/** Final output S3 URI (`s3://bucket/key.mp4`). */
8181
OutputS3Uri: string;
8282
/** Output container format; drives file vs frame-dir handling. */
83-
Format: "mp4" | "mov" | "png-sequence";
83+
Format: "mp4" | "mov" | "png-sequence" | "webm";
8484
}
8585

8686
/**
@@ -106,7 +106,7 @@ export interface PlanLambdaResult {
106106
Fps: 24 | 30 | 60;
107107
Width: number;
108108
Height: number;
109-
Format: "mp4" | "mov" | "png-sequence";
109+
Format: "mp4" | "mov" | "png-sequence" | "webm";
110110
HasAudio: boolean;
111111
AudioS3Uri: string | null;
112112
FfmpegVersion: string;

packages/aws-lambda/src/formatExtension.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,20 @@
66
* looks like vs a png-sequence.
77
*/
88

9-
export type DistributedFormat = "mp4" | "mov" | "png-sequence";
9+
export type DistributedFormat = "mp4" | "mov" | "png-sequence" | "webm";
10+
11+
// Closed-enum lookup table. TS enforces exhaustiveness via the
12+
// `Record<DistributedFormat, string>` annotation — adding a format to
13+
// `DistributedFormat` without adding the matching key here fails to
14+
// typecheck, which is the same exhaustiveness guarantee a switch +
15+
// `_exhaustive: never` arm provides but at lower complexity.
16+
const FORMAT_EXTENSIONS: Record<DistributedFormat, string> = {
17+
mp4: ".mp4",
18+
mov: ".mov",
19+
webm: ".webm",
20+
"png-sequence": "",
21+
};
1022

1123
export function formatExtension(format: DistributedFormat): string {
12-
switch (format) {
13-
case "mp4":
14-
return ".mp4";
15-
case "mov":
16-
return ".mov";
17-
case "png-sequence":
18-
return "";
19-
default: {
20-
const _exhaustive: never = format;
21-
throw new Error(`[formatExtension] unsupported format: ${_exhaustive as string}`);
22-
}
23-
}
24+
return FORMAT_EXTENSIONS[format];
2425
}

packages/aws-lambda/src/handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ async function downloadChunkObjects(
433433
s3: S3Client,
434434
uris: string[],
435435
workDir: string,
436-
format: "mp4" | "mov" | "png-sequence",
436+
format: "mp4" | "mov" | "png-sequence" | "webm",
437437
): Promise<string[]> {
438438
const chunksDir = join(workDir, "chunks");
439439
mkdirSync(chunksDir, { recursive: true });

packages/aws-lambda/src/sdk/validateConfig.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe("validateDistributedRenderConfig", () => {
5454
"unsupported format",
5555
{
5656
...VALID,
57-
format: "webm",
57+
format: "gif",
5858
} as unknown as SerializableDistributedRenderConfig,
5959
"config.format",
6060
],

packages/aws-lambda/src/sdk/validateConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class InvalidConfigError extends Error {
3131
}
3232

3333
const ALLOWED_FPS = [24, 30, 60] as const;
34-
const ALLOWED_FORMATS = ["mp4", "mov", "png-sequence"] as const;
34+
const ALLOWED_FORMATS = ["mp4", "mov", "png-sequence", "webm"] as const;
3535
const ALLOWED_CODECS = ["h264", "h265"] as const;
3636
const ALLOWED_QUALITIES = ["draft", "standard", "high"] as const;
3737
const ALLOWED_RUNTIME_CAPS = ["lambda", "temporal", "cloud-run-job", "k8s-job", "none"] as const;

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: 49 additions & 31 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

@@ -642,10 +653,17 @@ export async function plan(
642653
// move the contents over once the staged work completes.
643654
const finalCompiledDir = join(planDir, "compiled");
644655

645-
// mov + png-sequence carry alpha — flip force-screenshot so compileStage
646-
// takes the alpha-aware capture path (BeginFrame doesn't preserve alpha
647-
// on Linux headless-shell).
648-
const needsAlpha = config.format === "png-sequence" || config.format === "mov";
656+
// webm + mov + png-sequence carry alpha — flip force-screenshot so
657+
// compileStage takes the alpha-aware capture path (BeginFrame doesn't
658+
// preserve alpha on Linux headless-shell). Must match the in-process
659+
// renderer's needsAlpha logic in `renderOrchestrator.ts` so chunked
660+
// webm output preserves the same alpha plane the in-process baseline
661+
// does. Omitting webm here silently freezes `forceScreenshot: false`
662+
// into the planDir and every chunk worker captures opaque RGB — the
663+
// libvpx-vp9 alpha sub-stream then encodes either uniform alpha or
664+
// gets downgraded by the encoder, producing un-keyable webm output.
665+
const needsAlpha =
666+
config.format === "png-sequence" || config.format === "mov" || config.format === "webm";
649667

650668
// ── Compile ──
651669
const compileResult = await runCompileStage({

0 commit comments

Comments
 (0)