diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index 966f34b9a..2106a90b3 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -164,9 +164,12 @@ describe("plan() — golden planDir + planHash determinism", () => { async () => { const planDir = join(runRoot, "plan-layout"); mkdirSync(planDir, { recursive: true }); + // Pin chunkSize=240 so this fixture exercises the single-chunk path + // (totalFrames=30 → ceil(30/240)=1 chunk). The auto-sized variant + // (chunkSize=undefined) is exercised by the dedicated test below. const result = await plan( projectDir, - { fps: 30, width: 320, height: 240, format: "mp4" }, + { fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 }, planDir, ); @@ -185,7 +188,7 @@ describe("plan() — golden planDir + planHash determinism", () => { // ── PlanResult contract ───────────────────────────────────────────── expect(result.planDir).toBe(planDir); expect(result.planHash).toMatch(/^[0-9a-f]{64}$/); - expect(result.chunkCount).toBeGreaterThanOrEqual(1); + expect(result.chunkCount).toBe(1); expect(result.totalFrames).toBe(30); // 1s @ 30fps expect(result.width).toBe(320); expect(result.height).toBe(240); @@ -218,6 +221,37 @@ describe("plan() — golden planDir + planHash determinism", () => { TIMEOUT_MS, ); + it( + "auto-sizes chunkSize end-to-end when caller omits it", + async () => { + // Integration check that the auto-sizer wired through plan() actually + // produces multi-chunk output for the same fixture that single-chunks + // when chunkSize is pinned. With totalFrames=30 and the default + // maxParallelChunks=16, the auto-sizer picks + // max(MIN_CHUNK_SIZE=10, ceil(30/16)=2) = 10 → ceil(30/10) = 3 chunks. + const planDir = join(runRoot, "plan-autosized"); + mkdirSync(planDir, { recursive: true }); + const result = await plan( + projectDir, + { fps: 30, width: 320, height: 240, format: "mp4" }, + planDir, + ); + expect(result.chunkCount).toBe(3); + const chunks = JSON.parse( + readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"), + ) as Array<{ index: number; startFrame: number; endFrame: number }>; + expect(chunks).toHaveLength(3); + // Encoder gopSize must follow the auto-sized chunk so chunk-boundary + // IDR keyframes still land at frame 0 of each chunk. + const encoder = JSON.parse( + readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"), + ) as Record; + expect(encoder.gopSize).toBe(10); + expect(encoder.chunkSize).toBe(10); + }, + TIMEOUT_MS, + ); + it( "produces a byte-identical planHash on a second invocation", async () => { diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index e8cbcea62..821bc27b2 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -109,6 +109,13 @@ export interface DistributedRenderConfig { * ceil(totalFrames / maxParallelChunks))`. The auto-size floor * (`MIN_CHUNK_SIZE = 10`) keeps per-chunk fixed overhead from * swamping the parallelism gain on tiny renders. + * + * `effectiveChunkSize` also drives `LockedRenderConfig.gopSize` — every + * chunk's first frame is an IDR keyframe, so smaller chunks mean a + * tighter GOP and larger encoded files. Callers who optimize for + * output bytes (rather than wall-clock parallelism) should pass an + * explicit `chunkSize` matching their target GOP — e.g. `240` for the + * old 8-second-GOP behavior. */ chunkSize?: number; /** Default `16`. Caps long renders to fewer-but-longer chunks for operational fairness. */ @@ -350,22 +357,22 @@ export function measurePlanDirBytes(planDir: string): number { /** * Compute `(chunkCount, effectiveChunkSize)` from total frames and the - * caller's chunking knobs: + * caller's chunking knobs. The operative chunk size is + * `resolvedChunkSize` — equal to `configChunkSize` when the caller + * passes one, otherwise auto-sized from `maxParallelChunks`: * - * chunkCount = min(maxParallelChunks, ceil(totalFrames / chunkSize)) - * effectiveChunkSize = max(configChunkSize, ceil(totalFrames / maxParallelChunks)) + * resolvedChunkSize = configChunkSize ?? max(MIN_CHUNK_SIZE, ceil(totalFrames / maxParallelChunks)) + * chunkCount = min(maxParallelChunks, ceil(totalFrames / resolvedChunkSize)) + * effectiveChunkSize = max(resolvedChunkSize, ceil(totalFrames / chunkCount)) * * Long renders auto-rescale to fewer-but-longer chunks rather than * fragmenting infinitely. Returned `chunkCount >= 1` (`totalFrames === 0` - * is rejected upstream); `effectiveChunkSize >= configChunkSize`. + * is rejected upstream); `effectiveChunkSize >= resolvedChunkSize`. * - * When `configChunkSize` is `undefined`, the input is auto-sized from - * `maxParallelChunks`: `max(MIN_CHUNK_SIZE, ceil(totalFrames / - * maxParallelChunks))`. This honors the caller's fan-out intent — passing - * `maxParallelChunks=16` without `chunkSize` now produces 16 chunks - * (subject to the `MIN_CHUNK_SIZE` floor on tiny renders) instead of - * silently clamping to a 240-frame default. Explicit numbers, including - * `240`, take precedence over the auto-sizer. + * The auto-sizer (triggered when `configChunkSize` is `undefined`) honors + * the caller's fan-out intent: passing `maxParallelChunks=16` without + * `chunkSize` produces 16 chunks (subject to the `MIN_CHUNK_SIZE` floor + * on tiny renders). Explicit numbers, including `240`, take precedence. */ export function resolveChunkPlan( totalFrames: number,