mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 04:46:59 +00:00
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:
@@ -218,6 +218,7 @@ export {
|
|||||||
resolveVideoExtractionWindow,
|
resolveVideoExtractionWindow,
|
||||||
rebaseVideoToWindow,
|
rebaseVideoToWindow,
|
||||||
resolveFinalFrameExtractionWindow,
|
resolveFinalFrameExtractionWindow,
|
||||||
|
resolveFrameFormat,
|
||||||
resolveVideoExtractionDuration,
|
resolveVideoExtractionDuration,
|
||||||
resolvePlayableVideoDuration,
|
resolvePlayableVideoDuration,
|
||||||
extractionFrameCountForDuration,
|
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";
|
} from "../renderOrchestrator.js";
|
||||||
import { closeFileServerSafely } from "../fileServer.js";
|
import { closeFileServerSafely } from "../fileServer.js";
|
||||||
import { measurePlanSecondsPerFrame } from "./planFrameCost.js";
|
import { measurePlanSecondsPerFrame } from "./planFrameCost.js";
|
||||||
|
import { buildWeightedChunkSlices, planFrameWeights } from "./chunkWeights.js";
|
||||||
import { runAudioStage } from "../render/stages/audioStage.js";
|
import { runAudioStage } from "../render/stages/audioStage.js";
|
||||||
import { runCompileStage } from "../render/stages/compileStage.js";
|
import { runCompileStage } from "../render/stages/compileStage.js";
|
||||||
import {
|
import {
|
||||||
@@ -431,6 +432,8 @@ export const DEFAULT_MAX_PARALLEL_CHUNKS = 16;
|
|||||||
* outweighs the parallelism gain on tiny renders.
|
* outweighs the parallelism gain on tiny renders.
|
||||||
*/
|
*/
|
||||||
export const MIN_CHUNK_SIZE = 10;
|
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`. */
|
/** Most chunks one render may plan; `validateDistributedRenderConfig` enforces it on `maxParallelChunks`. */
|
||||||
export const MAX_PARALLEL_CHUNKS_CEILING = 256;
|
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
|
// 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`
|
* 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.
|
* (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)));
|
const wide = Math.max(1, Math.min(wave, Math.floor(totalFrames / minFramesPerChunk)));
|
||||||
if (workerTimeoutSeconds === undefined) return wide;
|
if (workerTimeoutSeconds === undefined) return wide;
|
||||||
|
|
||||||
const maxFramesPerChunk = Math.floor(
|
const maxFramesPerChunk = framesWithinWorkerTimeout(workerTimeoutSeconds, secondsPerFrame);
|
||||||
(workerTimeoutSeconds * WORKER_TIMEOUT_MARGIN - ESTIMATED_CHUNK_OVERHEAD_SECONDS) /
|
|
||||||
secondsPerFrame,
|
|
||||||
);
|
|
||||||
const needed = maxFramesPerChunk < 1 ? Infinity : Math.ceil(totalFrames / maxFramesPerChunk);
|
const needed = maxFramesPerChunk < 1 ? Infinity : Math.ceil(totalFrames / maxFramesPerChunk);
|
||||||
if (needed > MAX_PARALLEL_CHUNKS_CEILING) {
|
if (needed > MAX_PARALLEL_CHUNKS_CEILING) {
|
||||||
const framesAtCeiling = Math.ceil(totalFrames / MAX_PARALLEL_CHUNKS_CEILING);
|
const framesAtCeiling = Math.ceil(totalFrames / MAX_PARALLEL_CHUNKS_CEILING);
|
||||||
@@ -837,6 +845,29 @@ export function buildChunkSlices(
|
|||||||
return slices;
|
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`.
|
* Hash the deterministic-font bundle that ships inside `@hyperframes/producer`.
|
||||||
* The compiled HTML already inlines per-family `@font-face` data URIs, so the
|
* 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 digests: PlanFileDigests = options.digests ?? new Map();
|
||||||
const separateAudio = plansSeparateAudio(config, options);
|
const separateAudio = plansSeparateAudio(config, options);
|
||||||
const chunksCarryAudio = !separateAudio && carriesChunkAudio(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();
|
stageStarted = Date.now();
|
||||||
const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
|
const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
|
||||||
separateAudio
|
separateAudio
|
||||||
@@ -1580,7 +1619,18 @@ export async function buildLocalExecutionPlan(
|
|||||||
maxParallel,
|
maxParallel,
|
||||||
config.targetChunkFrames,
|
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;
|
const audioResult = audioOutcome.value;
|
||||||
if (audioResult?.audioError) {
|
if (audioResult?.audioError) {
|
||||||
applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
|
applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import {
|
|||||||
} from "@hyperframes/engine";
|
} from "@hyperframes/engine";
|
||||||
import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
||||||
import { type DistributedFormat } from "./shared.js";
|
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`,
|
* `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 MAX_DIMENSION = 7680;
|
||||||
const MIN_DIMENSION = 16;
|
const MIN_DIMENSION = 16;
|
||||||
const MAX_CHUNK_SIZE = 3600;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throw an `InvalidConfigError` if `config` is not a valid
|
* Throw an `InvalidConfigError` if `config` is not a valid
|
||||||
|
|||||||
Reference in New Issue
Block a user