From aae32e43eddb39c5ac698e4ed0dc98e175c9dd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 25 Oct 2026 11:25:00 +0000 Subject: [PATCH] perf(producer): cut distributed chunks by estimated capture cost (#6295) * perf(producer): cut distributed chunks by estimated capture cost, same chunk count Auto-sized plans place chunk boundaries so each chunk carries about the same estimated capture cost (page base plus active video megapixels); chunk count, explicit chunkSize, uniform-cost compositions and the targetChunkFrames ceiling are unchanged. Frame-cost calibration samples by cost interval plus the heaviest frame, so a heavy window is always measured. * fix(producer): hold cost-weighted chunks to the worker time limit's frame cap The weighted cut could grow a chunk up to MAX_CHUNK_SIZE frames, past the per-chunk frame count resolveMaxParallelChunks sized against the worker time limit. The cut now takes the same cap, from one shared helper, and keeps the equal-frame size as its floor. * fix(producer): keep the chunk-cut doc comment under 120 characters * perf(producer): drop the calibration sampler change from the cost-weighted cut The cut no longer touches how capture cost is measured; that keeps the producer bundle within its size budget and the change to the chunk boundaries alone. * perf(producer): compute the chunk frame cap inline to keep the bundle small --- packages/engine/src/index.ts | 1 + .../services/distributed/chunkWeights.test.ts | 208 ++++++++++++++++++ .../src/services/distributed/chunkWeights.ts | 89 ++++++++ .../producer/src/services/distributed/plan.ts | 60 ++++- .../distributed/renderConfigValidation.ts | 7 +- 5 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 packages/producer/src/services/distributed/chunkWeights.test.ts create mode 100644 packages/producer/src/services/distributed/chunkWeights.ts diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b54686ce5a..cae3a12e6e 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -218,6 +218,7 @@ export { resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, + resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, diff --git a/packages/producer/src/services/distributed/chunkWeights.test.ts b/packages/producer/src/services/distributed/chunkWeights.test.ts new file mode 100644 index 0000000000..d7e3ae2fbc --- /dev/null +++ b/packages/producer/src/services/distributed/chunkWeights.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "bun:test"; +import { buildWeightedChunkSlices, planFrameWeights } from "./chunkWeights.js"; +import { + buildChunkSlices, + buildPlanChunkSlices, + MAX_CHUNK_SIZE, + MIN_CHUNK_SIZE, + resolveChunkPlan, +} from "./plan.js"; +import type { PlanVideosJson } from "./shared.js"; + +type Slices = ReturnType; + +function chunkCosts(weights: Float64Array, slices: Slices) { + return slices.map((slice) => + weights.slice(slice.startFrame, slice.endFrame).reduce((sum, weight) => sum + weight, 0), + ); +} + +function expectExactCover(slices: Slices, totalFrames: number) { + expect(slices[0]?.startFrame).toBe(0); + expect(slices.at(-1)?.endFrame).toBe(totalFrames); + slices.forEach((slice, index) => { + expect(slice.index).toBe(index); + expect(slice.endFrame).toBeGreaterThan(slice.startFrame); + if (index > 0) expect(slice.startFrame).toBe(slices[index - 1]?.endFrame ?? -1); + }); +} + +function weightsWithHeavyWindow(totalFrames: number, from: number, to: number, heavy: number) { + const weights = new Float64Array(totalFrames).fill(1); + weights.fill(heavy, from, to); + return weights; +} + +function video(id: string, start: number, end: number, hidden?: boolean) { + return { id, start, end, hidden } as PlanVideosJson["videos"][number]; +} + +function extractedVideo(videoId: string, width: number, height: number, hasAlpha = false) { + return { + videoId, + metadata: { width, height, hasAlpha, videoCodec: "h264" }, + } as PlanVideosJson["extracted"][number]; +} + +describe("buildPlanChunkSlices", () => { + it.each([ + [1500, 18], + [660, 16], + [121, 12], + [50, 16], + [1, 16], + [54000, 256], + ])("uniform cost keeps the equal-frame cut (%d frames, max %d chunks)", (frames, max) => { + const { chunkCount, effectiveChunkSize } = resolveChunkPlan(frames, undefined, max); + const fullLengthVideo = planFrameWeights({ + videos: [video("v", 0, frames)], + extracted: [extractedVideo("v", 1920, 1080)], + totalFrames: frames, + fps: 1, + width: 1920, + height: 1080, + }); + for (const weights of [new Float64Array(frames).fill(0.7), fullLengthVideo]) { + expect(buildPlanChunkSlices(weights, {}, chunkCount, effectiveChunkSize)).toEqual( + buildChunkSlices(frames, chunkCount, effectiveChunkSize), + ); + } + }); + + it("balances a heavy window across the same number of chunks", () => { + const frames = 1500; + const { chunkCount, effectiveChunkSize } = resolveChunkPlan(frames, undefined, 18); + const weights = weightsWithHeavyWindow(frames, 700, 783, 4); + const slices = buildPlanChunkSlices(weights, {}, chunkCount, effectiveChunkSize); + + expect(slices).toHaveLength(chunkCount); + expectExactCover(slices, frames); + for (const slice of slices) { + expect(slice.endFrame - slice.startFrame).toBeGreaterThanOrEqual(MIN_CHUNK_SIZE); + } + const costs = chunkCosts(weights, slices); + const mean = costs.reduce((sum, cost) => sum + cost, 0) / costs.length; + expect(Math.max(...costs)).toBeLessThanOrEqual(mean * 1.15); + + const uniformCosts = chunkCosts( + weights, + buildChunkSlices(frames, chunkCount, effectiveChunkSize), + ); + expect(Math.max(...uniformCosts)).toBeGreaterThan(mean * 1.5); + const heavyChunk = slices.find((slice) => slice.startFrame <= 740 && slice.endFrame > 740); + expect((heavyChunk?.endFrame ?? 0) - (heavyChunk?.startFrame ?? 0)).toBeLessThan( + effectiveChunkSize, + ); + }); + + it("keeps the equal-frame cut when the caller passed a chunkSize", () => { + const weights = weightsWithHeavyWindow(1500, 700, 783, 4); + expect(buildPlanChunkSlices(weights, { chunkSize: 84 }, 18, 84)).toEqual( + buildChunkSlices(1500, 18, 84), + ); + }); + + it.each([ + [25, 16], + [11, 16], + [12, 1], + ])("stays a valid cover on tiny compositions (%d frames, max %d chunks)", (frames, max) => { + const { chunkCount, effectiveChunkSize } = resolveChunkPlan(frames, undefined, max); + const weights = weightsWithHeavyWindow(frames, 0, 3, 6); + const slices = buildPlanChunkSlices(weights, {}, chunkCount, effectiveChunkSize); + expect(slices).toHaveLength(chunkCount); + expectExactCover(slices, frames); + for (const slice of slices) { + expect(slice.endFrame - slice.startFrame).toBeGreaterThanOrEqual( + Math.floor(frames / chunkCount), + ); + } + }); + + it("keeps every chunk under MAX_CHUNK_SIZE or the equal cut, whichever is larger", () => { + const frames = 9000; + const weights = weightsWithHeavyWindow(frames, 0, 100, 50); + const slices = buildPlanChunkSlices(weights, {}, 2, 4500); + expectExactCover(slices, frames); + for (const slice of slices) expect(slice.endFrame - slice.startFrame).toBeLessThanOrEqual(4500); + + const fourChunks = buildPlanChunkSlices( + weightsWithHeavyWindow(frames, 0, 100, 200), + {}, + 4, + 2250, + ); + expectExactCover(fourChunks, frames); + for (const slice of fourChunks) { + expect(slice.endFrame - slice.startFrame).toBeLessThanOrEqual(MAX_CHUNK_SIZE); + } + }); + + it("keeps targetChunkFrames as a per-chunk frame ceiling", () => { + const frames = 1500; + const { chunkCount, effectiveChunkSize } = resolveChunkPlan(frames, undefined, 18, 120); + const weights = weightsWithHeavyWindow(frames, 700, 783, 4); + const slices = buildPlanChunkSlices( + weights, + { targetChunkFrames: 120 }, + chunkCount, + effectiveChunkSize, + ); + expect(slices).toHaveLength(chunkCount); + expectExactCover(slices, frames); + for (const slice of slices) expect(slice.endFrame - slice.startFrame).toBeLessThanOrEqual(120); + }); +}); + +describe("buildWeightedChunkSlices", () => { + it("returns null for uniform cost so the caller keeps the equal-frame cut", () => { + expect(buildWeightedChunkSlices(new Float64Array(100).fill(2), 4, 10, 3600)).toBeNull(); + }); +}); + +describe("planFrameWeights", () => { + const base = { totalFrames: 100, fps: 10, width: 3840, height: 2160 }; + const uhd = (3840 * 2160) / 1e6; + + it("weights active frames by video megapixels and frame format", () => { + const weights = planFrameWeights({ + ...base, + videos: [video("jpg", 1, 3), video("png", 2, 4), video("hidden", 0, 10, true)], + extracted: [ + extractedVideo("jpg", 3840, 2160), + extractedVideo("png", 3840, 2160, true), + extractedVideo("hidden", 3840, 2160), + ], + }); + expect(weights[5]).toBeCloseTo(0.7); + expect(weights[15]).toBeCloseTo(0.7 + uhd); + expect(weights[25]).toBeCloseTo(0.7 + uhd + 1.5 * uhd); + expect(weights[35]).toBeCloseTo(0.7 + 1.5 * uhd); + expect(weights[40]).toBeCloseTo(0.7); + }); + + it("caps megapixels at the composition size and clamps windows to the timeline", () => { + const weights = planFrameWeights({ + ...base, + width: 1920, + height: 1080, + videos: [video("big", -5, 500), video("unextracted", 0, 5)], + extracted: [extractedVideo("big", 3840, 2160)], + }); + expect(weights).toHaveLength(100); + expect(weights[0]).toBeCloseTo(0.7 + (1920 * 1080) / 1e6); + expect(weights[99]).toBeCloseTo(0.7 + (1920 * 1080) / 1e6); + }); + + it("holds every chunk to the worker time limit's frame cap", () => { + const frames = 1500; + const { chunkCount, effectiveChunkSize } = resolveChunkPlan(frames, undefined, 18); + const weights = weightsWithHeavyWindow(frames, 700, 783, 4); + const capped = buildPlanChunkSlices(weights, {}, chunkCount, effectiveChunkSize, 90); + expectExactCover(capped, frames); + for (const slice of capped) expect(slice.endFrame - slice.startFrame).toBeLessThanOrEqual(90); + + const uncapped = buildPlanChunkSlices(weights, {}, chunkCount, effectiveChunkSize); + expect(Math.max(...uncapped.map((s) => s.endFrame - s.startFrame))).toBeGreaterThan(90); + }); +}); diff --git a/packages/producer/src/services/distributed/chunkWeights.ts b/packages/producer/src/services/distributed/chunkWeights.ts new file mode 100644 index 0000000000..261d307185 --- /dev/null +++ b/packages/producer/src/services/distributed/chunkWeights.ts @@ -0,0 +1,89 @@ +import { resolveFrameFormat } from "@hyperframes/engine"; +import type { ChunkSliceJson } from "../render/stages/freezePlan.js"; +import type { PlanVideosJson } from "./shared.js"; + +/** Per-frame page cost, in megapixel-frames of jpg capture (measured: 0.03 s base / 0.045 s per MP). */ +const PAGE_BASE_WEIGHT = 0.7; +/** png frames cost ~1.5x jpg per megapixel. */ +const PNG_WEIGHT_FACTOR = 1.5; + +/** Costs whose max/min stay within 5% count as uniform. */ +function isUniformFrameCost(weights: Float64Array): boolean { + let lightest = Infinity; + let heaviest = 0; + for (const weight of weights) { + lightest = Math.min(lightest, weight); + heaviest = Math.max(heaviest, weight); + } + return heaviest <= lightest * 1.05; +} + +/** Estimated capture cost of every frame: page base plus each active video's pixels. */ +export function planFrameWeights(input: { + videos: PlanVideosJson["videos"]; + extracted: PlanVideosJson["extracted"]; + totalFrames: number; + fps: number; + width: number; + height: number; +}): Float64Array { + const { totalFrames, fps } = input; + const delta = new Float64Array(totalFrames + 1); + const outputPixels = input.width * input.height; + const extractedById = new Map(input.extracted.map((entry) => [entry.videoId, entry])); + for (const video of input.videos) { + const entry = extractedById.get(video.id); + if (!entry || video.hidden) continue; + const start = Math.max(0, Math.round(video.start * fps)); + const end = Math.min(totalFrames, Math.round(video.end * fps)); + if (!(end > start)) continue; + const { width, height } = entry.metadata; + const factor = resolveFrameFormat(entry.metadata) === "png" ? PNG_WEIGHT_FACTOR : 1; + const weight = (Math.min(width * height, outputPixels) / 1e6) * factor; + delta[start] = delta[start]! + weight; + delta[end] = delta[end]! - weight; + } + const weights = new Float64Array(totalFrames); + let active = 0; + for (let frame = 0; frame < totalFrames; frame++) { + active += delta[frame]!; + weights[frame] = PAGE_BASE_WEIGHT + active; + } + return weights; +} + +/** + * Cut `[0, weights.length)` into `chunkCount` ranges of ~equal summed weight, each within + * `[minFrames, maxFrames]` where the frame count allows. `null` when the cost is uniform. + */ +export function buildWeightedChunkSlices( + weights: Float64Array, + chunkCount: number, + minFrames: number, + maxFrames: number, +): ChunkSliceJson[] | null { + if (isUniformFrameCost(weights)) return null; + const totalFrames = weights.length; + const total = weights.reduce((sum, weight) => sum + weight, 0); + const minSize = Math.max(1, Math.min(minFrames, Math.floor(totalFrames / chunkCount))); + const maxSize = Math.max(maxFrames, Math.ceil(totalFrames / chunkCount)); + const slices: ChunkSliceJson[] = []; + let startFrame = 0; + let frame = 0; + let costBefore = 0; + for (let index = 0; index < chunkCount - 1; index++) { + const target = ((index + 1) * total) / chunkCount; + // Advance while the boundary after `frame` lands nearer the target than the one before it. + while (frame < totalFrames && costBefore + weights[frame]! / 2 < target) { + costBefore += weights[frame++]!; + } + const remaining = chunkCount - index - 1; + const low = Math.max(startFrame + minSize, totalFrames - remaining * maxSize); + const high = Math.min(startFrame + maxSize, totalFrames - remaining * minSize); + const endFrame = Math.min(high, Math.max(low, frame)); + slices.push({ index, startFrame, endFrame }); + startFrame = endFrame; + } + slices.push({ index: chunkCount - 1, startFrame, endFrame: totalFrames }); + return slices; +} diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index d94d212622..6ffbb966eb 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -62,6 +62,7 @@ import { } from "../renderOrchestrator.js"; import { closeFileServerSafely } from "../fileServer.js"; import { measurePlanSecondsPerFrame } from "./planFrameCost.js"; +import { buildWeightedChunkSlices, planFrameWeights } from "./chunkWeights.js"; import { runAudioStage } from "../render/stages/audioStage.js"; import { runCompileStage } from "../render/stages/compileStage.js"; import { @@ -431,6 +432,8 @@ export const DEFAULT_MAX_PARALLEL_CHUNKS = 16; * outweighs the parallelism gain on tiny renders. */ export const MIN_CHUNK_SIZE = 10; +/** Largest `chunkSize`; `validateDistributedRenderConfig` enforces it, and cost-weighted chunks stay under it. */ +export const MAX_CHUNK_SIZE = 3600; /** Most chunks one render may plan; `validateDistributedRenderConfig` enforces it on `maxParallelChunks`. */ export const MAX_PARALLEL_CHUNKS_CEILING = 256; // 0.11 s per megapixel-frame (~0.23 s per 1080p frame) was measured on one simple 1080p fixture on a @@ -499,6 +502,14 @@ export class ChunkExceedsWorkerTimeoutError extends Error { } } +/** Frames one chunk can capture inside the worker time limit, after the per-chunk overhead. */ +function framesWithinWorkerTimeout(workerTimeoutSeconds: number, secondsPerFrame: number): number { + return Math.floor( + (workerTimeoutSeconds * WORKER_TIMEOUT_MARGIN - ESTIMATED_CHUNK_OVERHEAD_SECONDS) / + secondsPerFrame, + ); +} + /** * The `maxParallelChunks` a plan uses: an explicit value, else one wave across `workerConcurrency` * (16 if unknown), widened to fit `workerTimeoutSeconds` unless chunkSize/targetChunkFrames pin it. @@ -531,10 +542,7 @@ export function resolveMaxParallelChunks( const wide = Math.max(1, Math.min(wave, Math.floor(totalFrames / minFramesPerChunk))); if (workerTimeoutSeconds === undefined) return wide; - const maxFramesPerChunk = Math.floor( - (workerTimeoutSeconds * WORKER_TIMEOUT_MARGIN - ESTIMATED_CHUNK_OVERHEAD_SECONDS) / - secondsPerFrame, - ); + const maxFramesPerChunk = framesWithinWorkerTimeout(workerTimeoutSeconds, secondsPerFrame); const needed = maxFramesPerChunk < 1 ? Infinity : Math.ceil(totalFrames / maxFramesPerChunk); if (needed > MAX_PARALLEL_CHUNKS_CEILING) { const framesAtCeiling = Math.ceil(totalFrames / MAX_PARALLEL_CHUNKS_CEILING); @@ -837,6 +845,29 @@ export function buildChunkSlices( return slices; } +/** + * The plan's chunk cut: auto-sized plans re-cut the same chunk count by estimated capture cost; + * explicit `chunkSize` or uniform cost keeps {@link buildChunkSlices}; `targetChunkFrames` and the time limit cap it. + */ +export function buildPlanChunkSlices( + frameWeights: Float64Array, + config: Pick, + chunkCount: number, + effectiveChunkSize: number, + maxFramesPerChunk = Infinity, +): ChunkSliceJson[] { + const weighted = + config.chunkSize === undefined + ? buildWeightedChunkSlices( + frameWeights, + chunkCount, + MIN_CHUNK_SIZE, + Math.min(config.targetChunkFrames ?? MAX_CHUNK_SIZE, MAX_CHUNK_SIZE, maxFramesPerChunk), + ) + : null; + return weighted ?? buildChunkSlices(frameWeights.length, chunkCount, effectiveChunkSize); +} + /** * Hash the deterministic-font bundle that ships inside `@hyperframes/producer`. * The compiled HTML already inlines per-family `@font-face` data URIs, so the @@ -1526,6 +1557,14 @@ export async function buildLocalExecutionPlan( const digests: PlanFileDigests = options.digests ?? new Map(); const separateAudio = plansSeparateAudio(config, options); const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options); + const frameWeights = planFrameWeights({ + videos: planVideosJson.videos, + extracted: planVideosJson.extracted, + totalFrames, + fps: config.fps, + width, + height, + }); stageStarted = Date.now(); const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([ separateAudio @@ -1580,7 +1619,18 @@ export async function buildLocalExecutionPlan( maxParallel, config.targetChunkFrames, ); - const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize); + const chunks = buildPlanChunkSlices( + frameWeights, + config, + chunkCount, + effectiveChunkSize, + chunkSizingUsesFrameCost(config) && config.workerTimeoutSeconds !== undefined + ? framesWithinWorkerTimeout( + config.workerTimeoutSeconds, + estimateFrameSeconds(megapixels, frameCostOutcome.value), + ) + : undefined, + ); const audioResult = audioOutcome.value; if (audioResult?.audioError) { applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log); diff --git a/packages/producer/src/services/distributed/renderConfigValidation.ts b/packages/producer/src/services/distributed/renderConfigValidation.ts index 8f579c4ce3..63ef23e0c9 100644 --- a/packages/producer/src/services/distributed/renderConfigValidation.ts +++ b/packages/producer/src/services/distributed/renderConfigValidation.ts @@ -24,7 +24,11 @@ import { } from "@hyperframes/engine"; import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core"; import { type DistributedFormat } from "./shared.js"; -import { type DistributedRenderConfig, MAX_PARALLEL_CHUNKS_CEILING } from "./plan.js"; +import { + type DistributedRenderConfig, + MAX_CHUNK_SIZE, + MAX_PARALLEL_CHUNKS_CEILING, +} from "./plan.js"; /** * `DistributedRenderConfig` minus the runtime-only fields (`logger`, @@ -62,7 +66,6 @@ const ALLOWED_HDR_MODES = ["auto", "force-sdr"] as const; const MAX_DIMENSION = 7680; const MIN_DIMENSION = 16; -const MAX_CHUNK_SIZE = 3600; /** * Throw an `InvalidConfigError` if `config` is not a valid