From 3b296d8571d0bf7f8db05863ee70f04bbe718a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 19:27:50 +0000 Subject: [PATCH 1/2] fix(engine): tune VP9 cpu-used across render paths --- docs/guides/performance.mdx | 14 ++++++++++ packages/engine/src/config.test.ts | 23 ++++++++++++++++ packages/engine/src/config.ts | 19 +++++++++++++- packages/engine/src/index.ts | 1 + .../engine/src/services/chunkEncoder.test.ts | 14 ++++++++-- packages/engine/src/services/chunkEncoder.ts | 21 +++++---------- .../engine/src/services/chunkEncoder.types.ts | 2 ++ .../src/services/streamingEncoder.test.ts | 24 +++++++++++++++++ .../engine/src/services/streamingEncoder.ts | 5 ++++ packages/engine/src/services/vp9Options.ts | 13 ++++++++++ packages/producer/README.md | 2 +- .../src/services/distributed/plan.test.ts | 26 +++++++++++++++++++ .../producer/src/services/distributed/plan.ts | 4 +++ .../src/services/distributed/renderChunk.ts | 4 +++ .../src/services/render/stages/encodeStage.ts | 4 ++- .../src/services/render/stages/freezePlan.ts | 6 +++++ .../src/services/renderOrchestrator.ts | 1 + 17 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 packages/engine/src/services/vp9Options.ts diff --git a/docs/guides/performance.mdx b/docs/guides/performance.mdx index 13ba34b774..597ea4a009 100644 --- a/docs/guides/performance.mdx +++ b/docs/guides/performance.mdx @@ -101,6 +101,20 @@ npx hyperframes render --quality draft --output preview.mp4 Draft quality renders fast and is visually close to the final render for everything except encoder-level detail. +## WebM encode speed + +Transparent WebM output uses FFmpeg's `libvpx-vp9` encoder. VP9 is CPU-heavy, so short overlay renders can spend most of their wall time in the encode stage even when frame capture is fast. + +HyperFrames sets `-cpu-used 4` for VP9 by default. On a devbox check using an 8s 1280×720, 15fps VP9-alpha encode, explicit `-cpu-used 4` cut encode time from 6.3s to 2.6s versus libvpx's default, with SSIM 0.9986 and PSNR 50.5dB against the default encode. Your composition and host CPU will move those numbers, but the direction is consistent: higher values trade some compression efficiency for faster WebM encodes. + +Tune per deployment with: + +```bash Terminal +PRODUCER_VP9_CPU_USED=2 npx hyperframes render --format webm --output overlay.webm +``` + +Valid values are integers from `-8` to `8`; HyperFrames clamps out-of-range values before passing them to FFmpeg. + ## Next Steps diff --git a/packages/engine/src/config.test.ts b/packages/engine/src/config.test.ts index 516282721a..6ca3fba90d 100644 --- a/packages/engine/src/config.test.ts +++ b/packages/engine/src/config.test.ts @@ -33,6 +33,7 @@ describe("resolveConfig", () => { expect(config.browserGpuMode).toBe("software"); expect(config.enableStreamingEncode).toBe(true); expect(config.streamingEncodeMaxDurationSeconds).toBe(240); + expect((config as Record).vp9CpuUsed).toBe(4); expect(config.audioGain).toBe(1); expect(config.debug).toBe(false); }); @@ -84,6 +85,28 @@ describe("resolveConfig", () => { expect(config.streamingEncodeMaxDurationSeconds).toBe(0); }); + it("reads VP9 cpu-used from env", () => { + setEnv("PRODUCER_VP9_CPU_USED", "6"); + + const config = resolveConfig(); + expect((config as Record).vp9CpuUsed).toBe(6); + }); + + it("falls back to the VP9 cpu-used default for invalid env values", () => { + setEnv("PRODUCER_VP9_CPU_USED", "fast"); + + const config = resolveConfig(); + expect((config as Record).vp9CpuUsed).toBe(4); + }); + + it("clamps VP9 cpu-used env values to libvpx's supported range", () => { + setEnv("PRODUCER_VP9_CPU_USED", "99"); + expect((resolveConfig() as Record).vp9CpuUsed).toBe(8); + + process.env.PRODUCER_VP9_CPU_USED = "-99"; + expect((resolveConfig() as Record).vp9CpuUsed).toBe(-8); + }); + it("treats non-'true' boolean env vars as false", () => { setEnv("PRODUCER_DISABLE_GPU", "yes"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index df05ebd96b..9efae85fa9 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -11,6 +11,7 @@ import { isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD, } from "./services/systemMemory.js"; +import { DEFAULT_VP9_CPU_USED, normalizeVp9CpuUsed } from "./services/vp9Options.js"; /** * Full engine configuration. All fields are wired through the config @@ -101,6 +102,11 @@ export interface EngineConfig { enablePageSideCompositing: boolean; // ── Encoding ───────────────────────────────────────────────────────── + /** + * libvpx-vp9 speed/quality tradeoff. Higher values encode faster with a + * larger quality/size tradeoff. FFmpeg accepts integer values from -8 to 8. + */ + vp9CpuUsed: number; enableChunkedEncode: boolean; chunkSizeFrames: number; enableStreamingEncode: boolean; @@ -220,6 +226,7 @@ export const DEFAULT_CONFIG: EngineConfig = { lowMemoryMode: false, enablePageSideCompositing: true, + vp9CpuUsed: DEFAULT_VP9_CPU_USED, enableChunkedEncode: false, chunkSizeFrames: 360, enableStreamingEncode: true, @@ -277,6 +284,11 @@ export function resolveConfig(overrides?: Partial): EngineConfig { if (raw === undefined) return fallback; return raw === "true"; }; + const envVp9CpuUsed = (): number => { + const raw = env("PRODUCER_VP9_CPU_USED"); + if (raw === undefined || raw === "") return DEFAULT_CONFIG.vp9CpuUsed; + return normalizeVp9CpuUsed(Number(raw)); + }; const envBrowserGpuMode = (): EngineConfig["browserGpuMode"] => { const raw = env("PRODUCER_BROWSER_GPU_MODE"); if (raw === "hardware" || raw === "software" || raw === "auto") return raw; @@ -326,6 +338,7 @@ export function resolveConfig(overrides?: Partial): EngineConfig { DEFAULT_CONFIG.enablePageSideCompositing, ), + vp9CpuUsed: envVp9CpuUsed(), enableChunkedEncode: envBool( "PRODUCER_ENABLE_CHUNKED_ENCODE", DEFAULT_CONFIG.enableChunkedEncode, @@ -392,9 +405,13 @@ export function resolveConfig(overrides?: Partial): EngineConfig { // Remove undefined values so they don't override defaults const cleanEnv = Object.fromEntries(Object.entries(fromEnv).filter(([, v]) => v !== undefined)); - return { + const merged = { ...DEFAULT_CONFIG, ...cleanEnv, ...overrides, }; + return { + ...merged, + vp9CpuUsed: normalizeVp9CpuUsed(merged.vp9CpuUsed), + }; } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a569e6699e..03ac54fe93 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -44,6 +44,7 @@ export type { // ── Configuration ────────────────────────────────────────────────────────────── export { resolveConfig, DEFAULT_CONFIG, type EngineConfig } from "./config.js"; +export { normalizeVp9CpuUsed } from "./services/vp9Options.js"; export { getSystemTotalMb, isLowMemorySystem, diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 5b0091639a..87599515a9 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -918,7 +918,7 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => { expect(args[args.indexOf("-g") + 1]).toBe("240"); expect(args[args.indexOf("-keyint_min") + 1]).toBe("240"); expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0"); - expect(args[args.indexOf("-cpu-used") + 1]).toBe("2"); + expect(args[args.indexOf("-cpu-used") + 1]).toBe("4"); expect(args[args.indexOf("-deadline") + 1]).toBe("good"); expect(args.indexOf("-x264-params")).toBe(-1); expect(args.indexOf("-x265-params")).toBe(-1); @@ -934,7 +934,7 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => { ); expect(args).not.toContain("-g"); expect(args).not.toContain("-keyint_min"); - expect(args).not.toContain("-cpu-used"); + expect(args[args.indexOf("-cpu-used") + 1]).toBe("4"); // The non-locked, non-alpha VP9 path leaves `-auto-alt-ref` at the // libvpx default. Alpha branches still emit `-auto-alt-ref 0` for an // unrelated reason (alpha + alt-ref is unsupported), but that's a @@ -942,6 +942,16 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => { expect(args).not.toContain("-auto-alt-ref"); }); + it("honors the resolved engine VP9 cpu-used override", () => { + const args = buildEncoderArgs( + { ...baseOptions, codec: "vp9", preset: "good", quality: 23, vp9CpuUsed: 6 }, + inputArgs, + "out.webm", + ); + + expect(args[args.indexOf("-cpu-used") + 1]).toBe("6"); + }); + it("true with alpha pixel format keeps alpha metadata and emits -auto-alt-ref once", () => { // Regression: alpha + closed-GOP must NOT double-push `-auto-alt-ref 0`. // Both paths want it disabled; the encoder branch emits it exactly once. diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index fb11da4680..6ad14e0c18 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -22,6 +22,7 @@ import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js"; import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; import { type Fps, fpsToFfmpegArg } from "@hyperframes/core"; import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; +import { appendVp9CpuUsedArg } from "./vp9Options.js"; export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; @@ -99,6 +100,7 @@ export function buildEncoderArgs( quality = 23, bitrate, pixelFormat = "yuv420p", + vp9CpuUsed, useGpu = false, } = options; @@ -269,14 +271,14 @@ export function buildEncoderArgs( args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality)); args.push("-deadline", preset === "ultrafast" ? "realtime" : "good"); args.push("-row-mt", "1"); + appendVp9CpuUsedArg(args, vp9CpuUsed); // `-auto-alt-ref 0` is mandatory for chunk concat-copy: libvpx-vp9's // alt-ref frames can reference frames in either direction inside a // GOP, so a chunk-boundary frame is not guaranteed to be the first - // displayable reference when alt-ref is on. `-cpu-used 2` pins the - // speed/quality tradeoff against libvpx-vp9 default drift across - // versions, so the planHash round-trips deterministically across - // worker images. + // displayable reference when alt-ref is on. The shared `vp9CpuUsed` + // option pins speed/quality against libvpx-vp9 default drift across + // versions for both chunked and streaming WebM encodes. const lockGopVp9 = options.lockGopForChunkConcat === true; if (lockGopVp9) { if ( @@ -289,16 +291,7 @@ export function buildEncoderArgs( ); } const gop = Math.floor(options.gopSize); - args.push( - "-g", - String(gop), - "-keyint_min", - String(gop), - "-auto-alt-ref", - "0", - "-cpu-used", - "2", - ); + args.push("-g", String(gop), "-keyint_min", String(gop), "-auto-alt-ref", "0"); } if (pixelFormat === "yuva420p") { // Alpha + alt-ref is unsupported by libvpx-vp9. The closed-GOP diff --git a/packages/engine/src/services/chunkEncoder.types.ts b/packages/engine/src/services/chunkEncoder.types.ts index b31b8fcfa7..936d1b17af 100644 --- a/packages/engine/src/services/chunkEncoder.types.ts +++ b/packages/engine/src/services/chunkEncoder.types.ts @@ -11,6 +11,8 @@ export interface EncoderOptions { quality?: number; bitrate?: string; pixelFormat?: string; + /** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */ + vp9CpuUsed?: number; useGpu?: boolean; hdr?: { transfer: HdrTransfer }; /** diff --git a/packages/engine/src/services/streamingEncoder.test.ts b/packages/engine/src/services/streamingEncoder.test.ts index a193f653db..b6265fd0fd 100644 --- a/packages/engine/src/services/streamingEncoder.test.ts +++ b/packages/engine/src/services/streamingEncoder.test.ts @@ -50,6 +50,15 @@ const baseSdr: StreamingEncoderOptions = { useGpu: false, }; +const baseVp9 = { + ...baseSdr, + codec: "vp9" as const, + preset: "good", + quality: 18, + pixelFormat: "yuva420p", + imageFormat: "png" as const, +}; + function getX265ParamsValue(args: string[]): string | undefined { const idx = args.indexOf("-x265-params"); return idx === -1 ? undefined : args[idx + 1]; @@ -166,6 +175,21 @@ describe("buildStreamingArgs", () => { }); }); + describe("VP9 cpu-used", () => { + it("emits the default speed/quality tradeoff for streaming WebM", () => { + const args = buildStreamingArgs(baseVp9, "/tmp/out.webm"); + + expect(args[args.indexOf("-c:v") + 1]).toBe("libvpx-vp9"); + expect(args[args.indexOf("-cpu-used") + 1]).toBe("4"); + }); + + it("honors the resolved engine override for streaming WebM", () => { + const args = buildStreamingArgs({ ...baseVp9, vp9CpuUsed: 2 }, "/tmp/out.webm"); + + expect(args[args.indexOf("-cpu-used") + 1]).toBe("2"); + }); + }); + describe("fps rational forwarding", () => { // Regression for the fps fraction-syntax feature: both `-framerate` // (input timestamping) and `-r` (output framerate) must carry the diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index ebb6f1a8fb..eb56c58bdb 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -30,6 +30,7 @@ import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; import { getHdrEncoderColorParams } from "../utils/hdr.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { fpsToFfmpegArg, type Fps } from "@hyperframes/core"; +import { appendVp9CpuUsedArg } from "./vp9Options.js"; // Re-export EncoderOptions so callers can reference the type via this module. export type { EncoderOptions } from "./chunkEncoder.types.js"; @@ -112,6 +113,8 @@ export interface StreamingEncoderOptions { quality?: number; bitrate?: string; pixelFormat?: string; + /** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */ + vp9CpuUsed?: number; useGpu?: boolean; imageFormat?: "jpeg" | "png"; hdr?: { transfer: import("../utils/hdr.js").HdrTransfer }; @@ -159,6 +162,7 @@ export function buildStreamingArgs( quality = 23, bitrate, pixelFormat = "yuv420p", + vp9CpuUsed, useGpu = false, imageFormat = "jpeg", } = options; @@ -300,6 +304,7 @@ export function buildStreamingArgs( args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality)); args.push("-deadline", preset === "ultrafast" ? "realtime" : "good"); args.push("-row-mt", "1"); + appendVp9CpuUsedArg(args, vp9CpuUsed); if (pixelFormat === "yuva420p") { args.push("-auto-alt-ref", "0"); args.push("-metadata:s:v:0", "alpha_mode=1"); diff --git a/packages/engine/src/services/vp9Options.ts b/packages/engine/src/services/vp9Options.ts new file mode 100644 index 0000000000..1bf64ad5f6 --- /dev/null +++ b/packages/engine/src/services/vp9Options.ts @@ -0,0 +1,13 @@ +export const DEFAULT_VP9_CPU_USED = 4; +const MIN_VP9_CPU_USED = -8; +const MAX_VP9_CPU_USED = 8; + +export function normalizeVp9CpuUsed(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_VP9_CPU_USED; + const integer = Math.trunc(value); + return Math.max(MIN_VP9_CPU_USED, Math.min(MAX_VP9_CPU_USED, integer)); +} + +export function appendVp9CpuUsedArg(args: string[], value: number | undefined): void { + args.push("-cpu-used", String(normalizeVp9CpuUsed(value))); +} diff --git a/packages/producer/README.md b/packages/producer/README.md index 990fdb28c6..8832a1d2cc 100644 --- a/packages/producer/README.md +++ b/packages/producer/README.md @@ -90,7 +90,7 @@ await executeRenderJob(job); The producer captures Chrome screenshots with the page background forced transparent (`html, body, [data-composition-id] { background: transparent !important }`) and the CDP default background override set to RGBA 0,0,0,0. The captured PNGs carry a real alpha channel and that channel is preserved end-to-end: -- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, and `alpha_mode=1` metadata. +- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, `-cpu-used 4` by default, and `alpha_mode=1` metadata. Tune the speed/quality tradeoff with `PRODUCER_VP9_CPU_USED` (`-8` to `8`). - ProRes 4444 (`mov`) is encoded with `-pix_fmt yuva444p10le`. - PNG sequences are written without re-encoding (zero-padded `frame_NNNNNN.png`). diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 9295b45faa..95a436dc68 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -706,6 +706,32 @@ describe("plan() — webm format (distributed VP9)", () => { // keyframe with no alt-ref references reaching back across seams. expect(encoder.closedGop).toBe(true); expect(encoder.gopSize).toBe(encoder.chunkSize); + expect(encoder.vp9CpuUsed).toBe(4); + }, + TIMEOUT_MS, + ); + + it( + "locks the resolved VP9 cpu-used value into encoder metadata", + async () => { + const planDir = join(runRoot, "plan-webm-vp9-cpu-used"); + mkdirSync(planDir, { recursive: true }); + await plan( + projectDir, + { + fps: 30, + width: 320, + height: 240, + format: "webm", + producerConfig: { vp9CpuUsed: 2 }, + }, + planDir, + ); + + const encoder = JSON.parse( + readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"), + ) as Record; + expect(encoder.vp9CpuUsed).toBe(2); }, TIMEOUT_MS, ); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 37cc8b66b0..946f2ee709 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -40,6 +40,7 @@ import { type EngineConfig, type VideoFrameFormat, getEncoderPreset, + normalizeVp9CpuUsed, resolveConfig, } from "@hyperframes/engine"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; @@ -573,6 +574,7 @@ function buildLockedRenderConfig(input: { forceScreenshot: boolean; deviceScaleFactor: number; ffmpegVersion: string; + engineConfig: Pick; effectiveChunkSize: number; chunkCount: number; runtimeEnv: Record; @@ -595,6 +597,7 @@ function buildLockedRenderConfig(input: { preset, crf: config.crf, bitrate: config.bitrate, + vp9CpuUsed: normalizeVp9CpuUsed(input.engineConfig.vp9CpuUsed), // GOP === chunkSize so every chunk's first frame is an IDR keyframe and // ffmpeg concat-copy round-trips losslessly. gopSize: input.effectiveChunkSize, @@ -954,6 +957,7 @@ export async function plan( forceScreenshot, deviceScaleFactor, ffmpegVersion, + engineConfig: cfg, effectiveChunkSize, chunkCount, runtimeEnv, diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 00c2887d2c..7018aaa78b 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -636,6 +636,10 @@ export async function renderChunk( preset, effectiveQuality, effectiveBitrate, + engineConfig: { + ffmpegEncodeTimeout: cfg.ffmpegEncodeTimeout, + vp9CpuUsed: encoder.vp9CpuUsed, + }, // Distributed chunks emit a single ffmpeg call per chunk; the // in-process per-chunk-within-chunk path would re-split our // already-chunked work. diff --git a/packages/producer/src/services/render/stages/encodeStage.ts b/packages/producer/src/services/render/stages/encodeStage.ts index 4c4ae8278f..2235971de8 100644 --- a/packages/producer/src/services/render/stages/encodeStage.ts +++ b/packages/producer/src/services/render/stages/encodeStage.ts @@ -86,7 +86,8 @@ export interface EncodeStageInput { enableChunkedEncode: boolean; chunkedEncodeSize: number; /** Already-resolved engine config from the orchestrator; direct callers fall back below. */ - engineConfig?: Pick; + engineConfig?: Pick & + Partial>; abortSignal: AbortSignal | undefined; assertNotAborted: () => void; onProgress?: ProgressCallback; @@ -288,6 +289,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise Date: Sat, 20 Jun 2026 20:11:53 +0000 Subject: [PATCH 2/2] fix: address VP9 review feedback --- .../migrating-to-hyperframes-lambda.mdx | 4 +-- docs/guides/performance.mdx | 6 ++++ docs/guides/remove-background.mdx | 4 +-- .../src/background-removal/pipeline.test.ts | 1 + .../cli/src/background-removal/pipeline.ts | 3 ++ packages/cli/src/commands/render.test.ts | 17 ++++++++++ packages/cli/src/commands/render.ts | 32 ++++++++++++++++++- packages/cli/src/docs/rendering.md | 1 + packages/cli/src/utils/dockerRunArgs.test.ts | 3 ++ packages/cli/src/utils/dockerRunArgs.ts | 2 ++ packages/engine/src/index.ts | 7 +++- packages/engine/src/services/vp9Options.ts | 4 +-- packages/producer/README.md | 2 +- .../src/services/animatedGifPrep.test.ts | 1 + .../producer/src/services/animatedGifPrep.ts | 4 ++- .../src/services/distributed/plan.test.ts | 1 + .../producer/src/services/distributed/plan.ts | 6 +++- .../services/distributed/renderChunk.test.ts | 15 +++++++++ .../src/services/distributed/renderChunk.ts | 12 ++++++- .../src/services/render/stages/encodeStage.ts | 3 +- .../src/services/render/stages/freezePlan.ts | 8 +++-- .../tests/distributed/webm-vp9/meta.json | 2 +- 22 files changed, 120 insertions(+), 18 deletions(-) diff --git a/docs/deploy/migrating-to-hyperframes-lambda.mdx b/docs/deploy/migrating-to-hyperframes-lambda.mdx index 69cdfe84ef..42eeb4efa8 100644 --- a/docs/deploy/migrating-to-hyperframes-lambda.mdx +++ b/docs/deploy/migrating-to-hyperframes-lambda.mdx @@ -73,9 +73,9 @@ HyperFrames refuses `data-gpu-mode="hardware"` in distributed mode — hardware ### webm uses closed-GOP VP9 -webm distributed renders go through libvpx-vp9 with `-g `, `-keyint_min `, `-auto-alt-ref 0`, and `-cpu-used 2`. The alt-ref disable is the load-bearing bit: libvpx-vp9's default non-displayable alt-ref frames can land anywhere in a GOP, which breaks concat-copy at chunk seams. Closed-GOP forces a keyframe at every chunk boundary so `ffmpeg -f concat -c copy` round-trips losslessly. Output is `yuva420p` to preserve alpha. Audio is muxed as Opus. +webm distributed renders go through libvpx-vp9 with `-g `, `-keyint_min `, `-auto-alt-ref 0`, and `-cpu-used 4` by default. The alt-ref disable is the load-bearing bit: libvpx-vp9's default non-displayable alt-ref frames can land anywhere in a GOP, which breaks concat-copy at chunk seams. Closed-GOP forces a keyframe at every chunk boundary so `ffmpeg -f concat -c copy` round-trips losslessly. Output is `yuva420p` to preserve alpha. Audio is muxed as Opus. -Distributed webm files are typically ~10-25% larger than the same composition rendered in-process at the same CRF, because closed-GOP forces more keyframes than the in-process single-pass would emit. Per-chunk encode is also slower than libvpx-vp9's default speed/quality tradeoff (`-cpu-used 2` is more conservative than the default for `-deadline good`). The single-machine in-process renderer remains the right choice for short webm renders; distributed pays for itself once a render's wall-clock exceeds what one machine delivers. +Distributed webm files are typically ~10-25% larger than the same composition rendered in-process at the same CRF, because closed-GOP forces more keyframes than the in-process single-pass would emit. VP9 encode speed is controlled by `PRODUCER_VP9_CPU_USED` (`-8` to `8`); use lower values for quality-sensitive or long-form WebM, and higher values when wall-clock encode time matters more than compression efficiency. The single-machine in-process renderer remains the right choice for short webm renders; distributed pays for itself once a render's wall-clock exceeds what one machine delivers. ### State files are local by default diff --git a/docs/guides/performance.mdx b/docs/guides/performance.mdx index 597ea4a009..f8f1c4c87a 100644 --- a/docs/guides/performance.mdx +++ b/docs/guides/performance.mdx @@ -113,6 +113,12 @@ Tune per deployment with: PRODUCER_VP9_CPU_USED=2 npx hyperframes render --format webm --output overlay.webm ``` +For one-off local renders, pass the same value directly: + +```bash Terminal +npx hyperframes render --format webm --vp9-cpu-used 2 --output overlay.webm +``` + Valid values are integers from `-8` to `8`; HyperFrames clamps out-of-range values before passing them to FFmpeg. ## Next Steps diff --git a/docs/guides/remove-background.mdx b/docs/guides/remove-background.mdx index 334bb11636..3a6f80f579 100644 --- a/docs/guides/remove-background.mdx +++ b/docs/guides/remove-background.mdx @@ -333,7 +333,7 @@ After running any of these externally, encode the output as a HyperFrames-compat ffmpeg -i frames-%04d.png -c:v libvpx-vp9 \ -pix_fmt yuva420p \ -metadata:s:v:0 alpha_mode=1 \ - -auto-alt-ref 0 -b:v 0 -crf 30 \ + -auto-alt-ref 0 -cpu-used 4 -b:v 0 -crf 30 \ transparent.webm ``` @@ -389,7 +389,7 @@ Chrome only reads the alpha plane when the WebM is encoded as `yuva420p` with th ffmpeg -i in.webm -c:v libvpx-vp9 \ -pix_fmt yuva420p \ -metadata:s:v:0 alpha_mode=1 \ - -auto-alt-ref 0 \ + -auto-alt-ref 0 -cpu-used 4 \ out.webm ``` diff --git a/packages/cli/src/background-removal/pipeline.test.ts b/packages/cli/src/background-removal/pipeline.test.ts index 5763efb06d..7aeaeeb63d 100644 --- a/packages/cli/src/background-removal/pipeline.test.ts +++ b/packages/cli/src/background-removal/pipeline.test.ts @@ -45,6 +45,7 @@ describe("background-removal/pipeline — buildEncoderArgs", () => { const args = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/out.webm"); expect(args).toContain("libvpx-vp9"); expect(args).toContain("yuva420p"); + expect(args[args.indexOf("-cpu-used") + 1]).toBe("4"); // The alpha_mode metadata must be present; without it Chrome ignores the alpha plane. const idx = args.indexOf("-metadata:s:v:0"); expect(idx).toBeGreaterThan(-1); diff --git a/packages/cli/src/background-removal/pipeline.ts b/packages/cli/src/background-removal/pipeline.ts index e899fa691e..ff19979263 100644 --- a/packages/cli/src/background-removal/pipeline.ts +++ b/packages/cli/src/background-removal/pipeline.ts @@ -18,6 +18,7 @@ import { extname } from "node:path"; import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { createSession, type Session } from "./inference.js"; import { type Device, type ModelId } from "./manager.js"; +import { DEFAULT_VP9_CPU_USED } from "@hyperframes/engine"; export type OutputFormat = "webm" | "mov" | "png"; @@ -159,6 +160,8 @@ export function buildEncoderArgs( "good", "-row-mt", "1", + "-cpu-used", + String(DEFAULT_VP9_CPU_USED), "-auto-alt-ref", "0", "-pix_fmt", diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index d4228055d9..6f35d40a64 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -312,6 +312,23 @@ describe("renderLocal browser GPU config", () => { }); }); + it("forwards vp9CpuUsed into resolveConfig when set", async () => { + await renderLocal("/tmp/project", "/tmp/out.webm", { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "webm", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + vp9CpuUsed: 2, + }); + + expect(producerState.resolveConfigCalls[0]).toMatchObject({ + vp9CpuUsed: 2, + }); + }); + it("omits pageNavigationTimeout from resolveConfig when --browser-timeout is not set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index b4fcbf34b9..c2ca73ab59 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -71,7 +71,12 @@ import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArg import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { runEnvironmentChecks } from "../browser/preflight.js"; import type { ProducerLogger, RenderJob } from "@hyperframes/producer"; -import { isVideoFrameFormat, type VideoFrameFormat } from "@hyperframes/engine"; +import { + MAX_VP9_CPU_USED, + MIN_VP9_CPU_USED, + isVideoFrameFormat, + type VideoFrameFormat, +} from "@hyperframes/engine"; import { normalizeResolutionFlag, parseFps, @@ -220,6 +225,11 @@ export default defineCommand({ type: "string", description: "Target video bitrate such as 10M. Mutually exclusive with --crf.", }, + "vp9-cpu-used": { + type: "string", + description: + "libvpx-vp9 -cpu-used value for WebM encodes (-8 to 8). Higher is faster with a larger quality/size tradeoff. Env: PRODUCER_VP9_CPU_USED.", + }, gpu: { type: "boolean", description: "Use GPU encoding", default: false }, "browser-gpu": { type: "boolean", @@ -569,6 +579,20 @@ export default defineCommand({ crf = parsed; } + let vp9CpuUsed: number | undefined; + if (args["vp9-cpu-used"] != null) { + const raw = args["vp9-cpu-used"]; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < MIN_VP9_CPU_USED || parsed > MAX_VP9_CPU_USED) { + errorBox( + "Invalid vp9-cpu-used", + `Got "${raw}". Must be an integer between ${MIN_VP9_CPU_USED} and ${MAX_VP9_CPU_USED}.`, + ); + process.exit(1); + } + vp9CpuUsed = parsed; + } + if (args["video-bitrate"] != null && !videoBitrate) { errorBox( "Invalid video-bitrate", @@ -730,6 +754,7 @@ export default defineCommand({ browserGpuMode, hdrMode, crf, + vp9CpuUsed, videoBitrate, quiet: batchQuiet, browserPath, @@ -786,6 +811,7 @@ export default defineCommand({ browserGpuMode, hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, + vp9CpuUsed, videoBitrate, videoFrameFormat, quiet, @@ -809,6 +835,7 @@ export default defineCommand({ browserGpuMode, hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto", crf, + vp9CpuUsed, videoBitrate, videoFrameFormat, quiet, @@ -845,6 +872,7 @@ interface RenderOptions { browserGpuMode?: "auto" | "hardware" | "software"; hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; + vp9CpuUsed?: number; videoBitrate?: string; videoFrameFormat?: VideoFrameFormat; quiet: boolean; @@ -1088,6 +1116,7 @@ async function renderDocker( browserGpu: options.browserGpuMode === "hardware", hdrMode: options.hdrMode, crf: options.crf, + vp9CpuUsed: options.vp9CpuUsed, videoBitrate: options.videoBitrate, videoFrameFormat: options.videoFrameFormat, quiet: options.quiet, @@ -1195,6 +1224,7 @@ export async function renderLocal( : {}), ...(options.protocolTimeout != null && { protocolTimeout: options.protocolTimeout }), ...(options.playerReadyTimeout != null && { playerReadyTimeout: options.playerReadyTimeout }), + ...(options.vp9CpuUsed != null ? { vp9CpuUsed: options.vp9CpuUsed } : {}), }), hdrMode: options.hdrMode, crf: options.crf, diff --git a/packages/cli/src/docs/rendering.md b/packages/cli/src/docs/rendering.md index 253eb33a41..86480c8044 100644 --- a/packages/cli/src/docs/rendering.md +++ b/packages/cli/src/docs/rendering.md @@ -19,6 +19,7 @@ Requires: Docker installed and running. - `-w, --workers` — Parallel workers 1-8 (default: auto) - `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`) - `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`) +- `--vp9-cpu-used` — WebM VP9 speed/quality tradeoff (`-8` to `8`, default: `4`). Higher values encode faster with larger output / quality tradeoff. - `--video-frame-format` — Source video frame extraction format: `auto`, `jpg`, or `png` (default: `auto`). Use `png` for UI recordings, screen captures, and color-sensitive source videos. - `--gpu` — Use GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) - `--browser-gpu` / `--no-browser-gpu` — Force host GPU or software (SwiftShader) for Chrome/WebGL capture. Default for local renders is `auto` — probe WebGL availability on first launch and fall back to software if no GPU is reachable. Docker mode always uses software. diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index a48c28ff4e..810c090ce4 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -167,6 +167,7 @@ describe("buildDockerRunArgs", () => { browserGpu: false, hdrMode: "force-hdr", crf: 16, + vp9CpuUsed: 2, videoBitrate: undefined, videoFrameFormat: "png", quiet: true, @@ -182,6 +183,8 @@ describe("buildDockerRunArgs", () => { expect(args).toContain("8"); expect(args).toContain("--crf"); expect(args).toContain("16"); + expect(args).toContain("--vp9-cpu-used"); + expect(args).toContain("2"); expect(args).toContain("--video-frame-format"); expect(args).toContain("png"); expect(args).toContain("--quiet"); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index df4b502dd2..832460e612 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -46,6 +46,7 @@ export interface DockerRenderOptions { browserGpu: boolean; hdrMode: "auto" | "force-hdr" | "force-sdr"; crf?: number; + vp9CpuUsed?: number; videoBitrate?: string; videoFrameFormat?: "auto" | "jpg" | "png"; quiet: boolean; @@ -121,6 +122,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { ...(options.gifLoop != null ? ["--gif-loop", String(options.gifLoop)] : []), ...(options.workers != null ? ["--workers", String(options.workers)] : []), ...(options.crf != null ? ["--crf", String(options.crf)] : []), + ...(options.vp9CpuUsed != null ? ["--vp9-cpu-used", String(options.vp9CpuUsed)] : []), ...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []), ...(options.videoFrameFormat && options.videoFrameFormat !== "auto" ? ["--video-frame-format", options.videoFrameFormat] diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 03ac54fe93..3117244154 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -44,7 +44,12 @@ export type { // ── Configuration ────────────────────────────────────────────────────────────── export { resolveConfig, DEFAULT_CONFIG, type EngineConfig } from "./config.js"; -export { normalizeVp9CpuUsed } from "./services/vp9Options.js"; +export { + DEFAULT_VP9_CPU_USED, + MAX_VP9_CPU_USED, + MIN_VP9_CPU_USED, + normalizeVp9CpuUsed, +} from "./services/vp9Options.js"; export { getSystemTotalMb, isLowMemorySystem, diff --git a/packages/engine/src/services/vp9Options.ts b/packages/engine/src/services/vp9Options.ts index 1bf64ad5f6..00daa9fa45 100644 --- a/packages/engine/src/services/vp9Options.ts +++ b/packages/engine/src/services/vp9Options.ts @@ -1,6 +1,6 @@ export const DEFAULT_VP9_CPU_USED = 4; -const MIN_VP9_CPU_USED = -8; -const MAX_VP9_CPU_USED = 8; +export const MIN_VP9_CPU_USED = -8; +export const MAX_VP9_CPU_USED = 8; export function normalizeVp9CpuUsed(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_VP9_CPU_USED; diff --git a/packages/producer/README.md b/packages/producer/README.md index 8832a1d2cc..9af9a80a43 100644 --- a/packages/producer/README.md +++ b/packages/producer/README.md @@ -90,7 +90,7 @@ await executeRenderJob(job); The producer captures Chrome screenshots with the page background forced transparent (`html, body, [data-composition-id] { background: transparent !important }`) and the CDP default background override set to RGBA 0,0,0,0. The captured PNGs carry a real alpha channel and that channel is preserved end-to-end: -- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, `-cpu-used 4` by default, and `alpha_mode=1` metadata. Tune the speed/quality tradeoff with `PRODUCER_VP9_CPU_USED` (`-8` to `8`). +- VP9 (`webm`) is encoded with `-pix_fmt yuva420p`, `-auto-alt-ref 0`, `-cpu-used 4` by default, and `alpha_mode=1` metadata. Tune the speed/quality tradeoff with `PRODUCER_VP9_CPU_USED` (`-8` to `8`) or local CLI `--vp9-cpu-used`. - ProRes 4444 (`mov`) is encoded with `-pix_fmt yuva444p10le`. - PNG sequences are written without re-encoding (zero-padded `frame_NNNNNN.png`). diff --git a/packages/producer/src/services/animatedGifPrep.test.ts b/packages/producer/src/services/animatedGifPrep.test.ts index 05bd9d42e9..69f68a01d7 100644 --- a/packages/producer/src/services/animatedGifPrep.test.ts +++ b/packages/producer/src/services/animatedGifPrep.test.ts @@ -78,6 +78,7 @@ describe("buildAnimatedGifTranscodeArgs", () => { expect(args).toContain("2"); expect(args).toContain("libvpx-vp9"); expect(args).toContain("yuva420p"); + expect(args[args.indexOf("-cpu-used") + 1]).toBe("4"); expect(args).toContain("-ignore_loop"); // Output goes to an extension-less tmp path; the muxer must be explicit. expect(args.join(" ")).toContain("-f webm"); diff --git a/packages/producer/src/services/animatedGifPrep.ts b/packages/producer/src/services/animatedGifPrep.ts index 1a85db12b8..9eb626ded0 100644 --- a/packages/producer/src/services/animatedGifPrep.ts +++ b/packages/producer/src/services/animatedGifPrep.ts @@ -13,7 +13,7 @@ import { import { dirname, isAbsolute, join, resolve } from "node:path"; import { parseHTML } from "linkedom"; import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core"; -import { getFfmpegBinary } from "@hyperframes/engine"; +import { DEFAULT_VP9_CPU_USED, getFfmpegBinary } from "@hyperframes/engine"; import { isHttpUrl } from "../utils/urlDownloader.js"; const PREPARED_GIF_SUBDIR = "_animated_gif"; @@ -227,6 +227,8 @@ export function buildAnimatedGifTranscodeArgs(input: { "0", "-deadline", "good", + "-cpu-used", + String(DEFAULT_VP9_CPU_USED), "-crf", "18", "-b:v", diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 95a436dc68..dec71f3d9d 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -509,6 +509,7 @@ describe("plan() — codec knob", () => { ) as Record; expect(encoder.encoder).toBe("libx264-software"); expect(encoder.pixelFormat).toBe("yuv420p"); + expect(encoder).not.toHaveProperty("vp9CpuUsed"); }, TIMEOUT_MS, ); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 946f2ee709..ce8c24fbf9 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -581,6 +581,10 @@ function buildLockedRenderConfig(input: { }): LockedRenderConfig { const { config, forceScreenshot, deviceScaleFactor, ffmpegVersion } = input; const { encoder, pixelFormat, preset } = resolveEncoderTriple(config); + const locksVp9CpuUsed = + encoder === "libvpx-vp9-software" + ? { vp9CpuUsed: normalizeVp9CpuUsed(input.engineConfig.vp9CpuUsed) } + : {}; return { captureMode: forceScreenshot ? "screenshot" : "beginframe", forceScreenshot, @@ -597,7 +601,7 @@ function buildLockedRenderConfig(input: { preset, crf: config.crf, bitrate: config.bitrate, - vp9CpuUsed: normalizeVp9CpuUsed(input.engineConfig.vp9CpuUsed), + ...locksVp9CpuUsed, // GOP === chunkSize so every chunk's first frame is an IDR keyframe and // ffmpeg concat-copy round-trips losslessly. gopSize: input.effectiveChunkSize, diff --git a/packages/producer/src/services/distributed/renderChunk.test.ts b/packages/producer/src/services/distributed/renderChunk.test.ts index 02edabd825..e88230a705 100644 --- a/packages/producer/src/services/distributed/renderChunk.test.ts +++ b/packages/producer/src/services/distributed/renderChunk.test.ts @@ -30,6 +30,7 @@ import { PLAN_HASH_MISMATCH, renderChunk, RenderChunkValidationError, + resolveLockedVp9CpuUsed, resolvePresetForLockedEncoder, } from "./renderChunk.js"; @@ -450,3 +451,17 @@ describe("resolvePresetForLockedEncoder", () => { expect(out).toBe(base); }); }); + +describe("resolveLockedVp9CpuUsed", () => { + it("uses the locked value for new VP9 planDirs", () => { + expect(resolveLockedVp9CpuUsed({ encoder: "libvpx-vp9-software", vp9CpuUsed: 4 })).toBe(4); + }); + + it("preserves legacy distributed VP9 replay behavior when the field is absent", () => { + expect(resolveLockedVp9CpuUsed({ encoder: "libvpx-vp9-software" })).toBe(2); + }); + + it("returns undefined for non-VP9 planDirs", () => { + expect(resolveLockedVp9CpuUsed({ encoder: "libx264-software" })).toBeUndefined(); + }); +}); diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 7018aaa78b..c6effc1fdb 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -93,6 +93,7 @@ export const PLAN_HASH_MISMATCH = "PLAN_HASH_MISMATCH"; export const MISSING_PLAN_ARTIFACT = "MISSING_PLAN_ARTIFACT"; export const CHUNK_INDEX_OUT_OF_RANGE = "CHUNK_INDEX_OUT_OF_RANGE"; export const MISSING_RUNTIME_ENV_SNAPSHOT = "MISSING_RUNTIME_ENV_SNAPSHOT"; +const LEGACY_DISTRIBUTED_VP9_CPU_USED = 2; export type RenderChunkValidationCode = | typeof FFMPEG_VERSION_MISMATCH @@ -295,6 +296,15 @@ export function resolvePresetForLockedEncoder< return basePreset; } +export function resolveLockedVp9CpuUsed( + lockedEncoder: Pick, +): number | undefined { + if (lockedEncoder.encoder !== "libvpx-vp9-software") return undefined; + // Pre-vp9CpuUsed WebM planDirs used the old closed-GOP literal. Keep replay + // bytes stable for those plans while new planDirs carry their resolved value. + return lockedEncoder.vp9CpuUsed ?? LEGACY_DISTRIBUTED_VP9_CPU_USED; +} + /** * Activity B: render a single chunk of the planDir. The `outputChunkPath` * argument is a file for mp4/mov outputs and a directory for png-sequence @@ -638,7 +648,7 @@ export async function renderChunk( effectiveBitrate, engineConfig: { ffmpegEncodeTimeout: cfg.ffmpegEncodeTimeout, - vp9CpuUsed: encoder.vp9CpuUsed, + vp9CpuUsed: resolveLockedVp9CpuUsed(encoder) ?? cfg.vp9CpuUsed, }, // Distributed chunks emit a single ffmpeg call per chunk; the // in-process per-chunk-within-chunk path would re-split our diff --git a/packages/producer/src/services/render/stages/encodeStage.ts b/packages/producer/src/services/render/stages/encodeStage.ts index 2235971de8..602a5a924d 100644 --- a/packages/producer/src/services/render/stages/encodeStage.ts +++ b/packages/producer/src/services/render/stages/encodeStage.ts @@ -86,8 +86,7 @@ export interface EncodeStageInput { enableChunkedEncode: boolean; chunkedEncodeSize: number; /** Already-resolved engine config from the orchestrator; direct callers fall back below. */ - engineConfig?: Pick & - Partial>; + engineConfig?: Pick; abortSignal: AbortSignal | undefined; assertNotAborted: () => void; onProgress?: ProgressCallback; diff --git a/packages/producer/src/services/render/stages/freezePlan.ts b/packages/producer/src/services/render/stages/freezePlan.ts index 8b4c5ed03c..58bccc0cdb 100644 --- a/packages/producer/src/services/render/stages/freezePlan.ts +++ b/packages/producer/src/services/render/stages/freezePlan.ts @@ -54,9 +54,11 @@ export interface LockedRenderConfig { crf?: number; bitrate?: string; /** - * New plans always persist the resolved libvpx-vp9 `-cpu-used` value so - * distributed workers reproduce the controller's VP9 speed/quality choice. - * Optional for compatibility with older planDirs. + * VP9-only: new WebM plans persist the resolved libvpx-vp9 `-cpu-used` + * value so distributed workers reproduce the controller's speed/quality + * choice. Omitted for non-VP9 plans to avoid shifting unrelated planHash + * baselines. Optional for compatibility with older WebM planDirs; workers + * replay those with the historical distributed VP9 value. */ vp9CpuUsed?: number; /** Equal to chunkSize for closed-GOP concat-copy. */ diff --git a/packages/producer/tests/distributed/webm-vp9/meta.json b/packages/producer/tests/distributed/webm-vp9/meta.json index 5f67e58f53..e119b565cc 100644 --- a/packages/producer/tests/distributed/webm-vp9/meta.json +++ b/packages/producer/tests/distributed/webm-vp9/meta.json @@ -1,6 +1,6 @@ { "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.", + "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 4 by default) 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,