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
This commit is contained in:
Miguel Ángel
2026-10-25 11:25:00 +00:00
committed by GitHub
parent 6b424e02c4
commit aae32e43ed
5 changed files with 358 additions and 7 deletions
+1
View File
@@ -218,6 +218,7 @@ export {
resolveVideoExtractionWindow,
rebaseVideoToWindow,
resolveFinalFrameExtractionWindow,
resolveFrameFormat,
resolveVideoExtractionDuration,
resolvePlayableVideoDuration,
extractionFrameCountForDuration,
@@ -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<typeof buildChunkSlices>;
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);
});
});
@@ -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;
}
@@ -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<DistributedRenderConfig, "chunkSize" | "targetChunkFrames">,
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);
@@ -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