mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #525 from heygen-com/fix/shader-audio-worker-fallback
fix: budget workers for expensive captures
This commit is contained in:
@@ -35,11 +35,6 @@ const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".
|
|||||||
|
|
||||||
const CPU_CORE_COUNT = cpus().length;
|
const CPU_CORE_COUNT = cpus().length;
|
||||||
|
|
||||||
/** 3/4 of CPU cores, capped at 8. Each worker spawns a Chrome process (~256 MB). */
|
|
||||||
function defaultWorkerCount(): number {
|
|
||||||
return Math.max(1, Math.min(Math.floor((CPU_CORE_COUNT * 3) / 4), 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineCommand({
|
export default defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "render",
|
name: "render",
|
||||||
@@ -216,12 +211,9 @@ export default defineCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Print render plan ─────────────────────────────────────────────────
|
// ── Print render plan ─────────────────────────────────────────────────
|
||||||
const workerCount = workers ?? defaultWorkerCount();
|
|
||||||
if (!quiet) {
|
if (!quiet) {
|
||||||
const workerLabel =
|
const workerLabel =
|
||||||
args.workers != null
|
workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
|
||||||
? `${workerCount} workers`
|
|
||||||
: `${workerCount} workers (auto — ${CPU_CORE_COUNT} cores detected)`;
|
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(
|
console.log(
|
||||||
c.accent("\u25C6") +
|
c.accent("\u25C6") +
|
||||||
@@ -307,7 +299,7 @@ export default defineCommand({
|
|||||||
fps,
|
fps,
|
||||||
quality,
|
quality,
|
||||||
format,
|
format,
|
||||||
workers: workerCount,
|
workers,
|
||||||
gpu: useGpu,
|
gpu: useGpu,
|
||||||
hdr: args.hdr ?? false,
|
hdr: args.hdr ?? false,
|
||||||
crf,
|
crf,
|
||||||
@@ -319,7 +311,7 @@ export default defineCommand({
|
|||||||
fps,
|
fps,
|
||||||
quality,
|
quality,
|
||||||
format,
|
format,
|
||||||
workers: workerCount,
|
workers,
|
||||||
gpu: useGpu,
|
gpu: useGpu,
|
||||||
hdr: args.hdr ?? false,
|
hdr: args.hdr ?? false,
|
||||||
crf,
|
crf,
|
||||||
@@ -335,7 +327,7 @@ interface RenderOptions {
|
|||||||
fps: 24 | 30 | 60;
|
fps: 24 | 30 | 60;
|
||||||
quality: "draft" | "standard" | "high";
|
quality: "draft" | "standard" | "high";
|
||||||
format: "mp4" | "webm" | "mov";
|
format: "mp4" | "webm" | "mov";
|
||||||
workers: number;
|
workers?: number;
|
||||||
gpu: boolean;
|
gpu: boolean;
|
||||||
hdr: boolean;
|
hdr: boolean;
|
||||||
crf?: number;
|
crf?: number;
|
||||||
@@ -604,7 +596,7 @@ function trackRenderMetrics(
|
|||||||
durationMs: elapsedMs,
|
durationMs: elapsedMs,
|
||||||
fps: options.fps,
|
fps: options.fps,
|
||||||
quality: options.quality,
|
quality: options.quality,
|
||||||
workers: options.workers,
|
workers: options.workers ?? perf?.workers,
|
||||||
docker,
|
docker,
|
||||||
gpu: options.gpu,
|
gpu: options.gpu,
|
||||||
compositionDurationMs,
|
compositionDurationMs,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function trackRenderComplete(props: {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
fps: number;
|
fps: number;
|
||||||
quality: string;
|
quality: string;
|
||||||
workers: number;
|
workers?: number;
|
||||||
docker: boolean;
|
docker: boolean;
|
||||||
gpu: boolean;
|
gpu: boolean;
|
||||||
// Composition metadata
|
// Composition metadata
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const BASE: DockerRenderOptions = {
|
|||||||
fps: 30,
|
fps: 30,
|
||||||
quality: "standard",
|
quality: "standard",
|
||||||
format: "mp4",
|
format: "mp4",
|
||||||
workers: 4,
|
|
||||||
gpu: false,
|
gpu: false,
|
||||||
hdr: false,
|
hdr: false,
|
||||||
crf: undefined,
|
crf: undefined,
|
||||||
@@ -43,17 +42,28 @@ describe("buildDockerRunArgs", () => {
|
|||||||
"standard",
|
"standard",
|
||||||
"--format",
|
"--format",
|
||||||
"mp4",
|
"mp4",
|
||||||
"--workers",
|
|
||||||
"4",
|
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits --workers when auto sizing should happen inside the container", () => {
|
||||||
|
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
|
||||||
|
expect(args).not.toContain("--workers");
|
||||||
|
});
|
||||||
|
|
||||||
it("matches snapshot when every renderer flag is enabled", () => {
|
it("matches snapshot when every renderer flag is enabled", () => {
|
||||||
expect(
|
expect(
|
||||||
buildDockerRunArgs({
|
buildDockerRunArgs({
|
||||||
...FIXED_INPUT,
|
...FIXED_INPUT,
|
||||||
options: { ...BASE, gpu: true, hdr: true, crf: 18, videoBitrate: undefined, quiet: true },
|
options: {
|
||||||
|
...BASE,
|
||||||
|
workers: 4,
|
||||||
|
gpu: true,
|
||||||
|
hdr: true,
|
||||||
|
crf: 18,
|
||||||
|
videoBitrate: undefined,
|
||||||
|
quiet: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
).toMatchInlineSnapshot(`
|
).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface DockerRenderOptions {
|
|||||||
fps: 24 | 30 | 60;
|
fps: 24 | 30 | 60;
|
||||||
quality: "draft" | "standard" | "high";
|
quality: "draft" | "standard" | "high";
|
||||||
format: "mp4" | "webm" | "mov";
|
format: "mp4" | "webm" | "mov";
|
||||||
workers: number;
|
workers?: number;
|
||||||
gpu: boolean;
|
gpu: boolean;
|
||||||
hdr: boolean;
|
hdr: boolean;
|
||||||
crf?: number;
|
crf?: number;
|
||||||
@@ -54,8 +54,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
|
|||||||
options.quality,
|
options.quality,
|
||||||
"--format",
|
"--format",
|
||||||
options.format,
|
options.format,
|
||||||
"--workers",
|
...(options.workers != null ? ["--workers", String(options.workers)] : []),
|
||||||
String(options.workers),
|
|
||||||
...(options.crf != null ? ["--crf", String(options.crf)] : []),
|
...(options.crf != null ? ["--crf", String(options.crf)] : []),
|
||||||
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
|
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
|
||||||
...(options.quiet ? ["--quiet"] : []),
|
...(options.quiet ? ["--quiet"] : []),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { distributeFrames } from "./parallelCoordinator.js";
|
import { calculateOptimalWorkers, distributeFrames } from "./parallelCoordinator.js";
|
||||||
|
|
||||||
describe("distributeFrames", () => {
|
describe("distributeFrames", () => {
|
||||||
it("distributes frames evenly across workers", () => {
|
it("distributes frames evenly across workers", () => {
|
||||||
@@ -42,3 +42,29 @@ describe("distributeFrames", () => {
|
|||||||
expect(tasks.map((t) => t.workerId)).toEqual([0, 1, 2]);
|
expect(tasks.map((t) => t.workerId)).toEqual([0, 1, 2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("calculateOptimalWorkers", () => {
|
||||||
|
it("lets high-cost auto renders fall back to one worker when CPU budget requires it", () => {
|
||||||
|
const workers = calculateOptimalWorkers(180, undefined, {
|
||||||
|
concurrency: 6,
|
||||||
|
coresPerWorker: 100,
|
||||||
|
minParallelFrames: 120,
|
||||||
|
largeRenderThreshold: 1000,
|
||||||
|
captureCostMultiplier: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workers).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply capture cost to explicit worker requests", () => {
|
||||||
|
const workers = calculateOptimalWorkers(180, 4, {
|
||||||
|
concurrency: 6,
|
||||||
|
coresPerWorker: 100,
|
||||||
|
minParallelFrames: 120,
|
||||||
|
largeRenderThreshold: 1000,
|
||||||
|
captureCostMultiplier: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workers).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export interface ParallelProgress {
|
|||||||
workerProgress: Map<number, number>;
|
workerProgress: Map<number, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkerSizingConfig extends Partial<
|
||||||
|
Pick<
|
||||||
|
EngineConfig,
|
||||||
|
"concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold"
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Relative per-frame capture cost for auto worker sizing. Values above 1
|
||||||
|
* represent compositions that put more CPU pressure on each Chrome worker
|
||||||
|
* than a plain DOM screenshot. Explicit --workers requests ignore this hint.
|
||||||
|
*/
|
||||||
|
captureCostMultiplier?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const MEMORY_PER_WORKER_MB = 256;
|
const MEMORY_PER_WORKER_MB = 256;
|
||||||
const MIN_WORKERS = 1;
|
const MIN_WORKERS = 1;
|
||||||
const ABSOLUTE_MAX_WORKERS = 10;
|
const ABSOLUTE_MAX_WORKERS = 10;
|
||||||
@@ -57,12 +71,7 @@ const MIN_FRAMES_PER_WORKER = 30;
|
|||||||
export function calculateOptimalWorkers(
|
export function calculateOptimalWorkers(
|
||||||
totalFrames: number,
|
totalFrames: number,
|
||||||
requested?: number,
|
requested?: number,
|
||||||
config?: Partial<
|
config?: WorkerSizingConfig,
|
||||||
Pick<
|
|
||||||
EngineConfig,
|
|
||||||
"concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold"
|
|
||||||
>
|
|
||||||
>,
|
|
||||||
): number {
|
): number {
|
||||||
// Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
|
// Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
|
||||||
const effectiveMaxWorkers = (() => {
|
const effectiveMaxWorkers = (() => {
|
||||||
@@ -76,6 +85,7 @@ export function calculateOptimalWorkers(
|
|||||||
const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
|
const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
|
||||||
const effectiveLargeRenderThreshold =
|
const effectiveLargeRenderThreshold =
|
||||||
config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
||||||
|
const captureCostMultiplier = Math.max(1, config?.captureCostMultiplier ?? 1);
|
||||||
|
|
||||||
if (requested !== undefined) {
|
if (requested !== undefined) {
|
||||||
return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
|
return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
|
||||||
@@ -98,13 +108,19 @@ export function calculateOptimalWorkers(
|
|||||||
const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
|
const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
|
||||||
let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
|
let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
|
||||||
|
|
||||||
// Adaptive scaling: cap workers for large renders to prevent CPU contention.
|
// Adaptive scaling: cap workers for large or expensive renders to prevent
|
||||||
// Each Chrome process (with SwiftShader) is CPU-heavy; too many on a long
|
// CPU contention. Each Chrome process (with SwiftShader) is CPU-heavy; too
|
||||||
// render causes protocol timeouts from compositor starvation.
|
// many concurrent captures can starve the compositor and surface as CDP
|
||||||
// Scale proportionally to CPU count: ~3 cores per worker (benchmarked).
|
// protocol timeouts. Scale proportionally to CPU count and composition cost:
|
||||||
// 8 cores → 2 workers, 16 cores → 5 workers, 32 cores → 10 workers.
|
// 8 cores → 2 workers, 16 cores → 5 workers, 32 cores → 10 workers.
|
||||||
if (totalFrames >= effectiveLargeRenderThreshold) {
|
const weightedFrames = totalFrames * captureCostMultiplier;
|
||||||
const cpuScaledMax = Math.max(2, Math.floor(cpuCount / effectiveCoresPerWorker));
|
const contentionThreshold = Math.max(
|
||||||
|
effectiveMinParallelFrames,
|
||||||
|
Math.floor(effectiveLargeRenderThreshold / 3),
|
||||||
|
);
|
||||||
|
if (totalFrames >= effectiveLargeRenderThreshold || weightedFrames >= contentionThreshold) {
|
||||||
|
const weightedCoresPerWorker = effectiveCoresPerWorker * captureCostMultiplier;
|
||||||
|
const cpuScaledMax = Math.max(MIN_WORKERS, Math.floor(cpuCount / weightedCoresPerWorker));
|
||||||
if (finalWorkers > cpuScaledMax) {
|
if (finalWorkers > cpuScaledMax) {
|
||||||
finalWorkers = cpuScaledMax;
|
finalWorkers = cpuScaledMax;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
collectExternalAssets,
|
collectExternalAssets,
|
||||||
compileForRender,
|
compileForRender,
|
||||||
detectRenderModeHints,
|
detectRenderModeHints,
|
||||||
|
detectShaderTransitionUsage,
|
||||||
inlineExternalScripts,
|
inlineExternalScripts,
|
||||||
recompileWithResolutions,
|
recompileWithResolutions,
|
||||||
} from "./htmlCompiler.js";
|
} from "./htmlCompiler.js";
|
||||||
@@ -459,6 +460,36 @@ describe("detectRenderModeHints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("detectShaderTransitionUsage", () => {
|
||||||
|
it("detects authored HyperShader initialization", () => {
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html><body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/shader-transitions/dist/index.global.js"></script>
|
||||||
|
<script>
|
||||||
|
window.HyperShader.init({
|
||||||
|
scenes: ["s1", "s2"],
|
||||||
|
transitions: [{ time: 1, shader: "cinematic-zoom", duration: 0.5 }],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
expect(detectShaderTransitionUsage(html)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores comments and external scripts by themselves", () => {
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html><body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/shader-transitions/dist/index.global.js"></script>
|
||||||
|
<script>
|
||||||
|
// window.HyperShader.init({ scenes: ["s1", "s2"], transitions: [] });
|
||||||
|
const label = "safe";
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
expect(detectShaderTransitionUsage(html)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("template-wrapped sub-composition media offsets", () => {
|
describe("template-wrapped sub-composition media offsets", () => {
|
||||||
function writeTemplateWrappedProject(
|
function writeTemplateWrappedProject(
|
||||||
hostAttrs: string,
|
hostAttrs: string,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface CompiledComposition {
|
|||||||
height: number;
|
height: number;
|
||||||
staticDuration: number;
|
staticDuration: number;
|
||||||
renderModeHints: RenderModeHints;
|
renderModeHints: RenderModeHints;
|
||||||
|
hasShaderTransitions: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
|
export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
|
||||||
@@ -124,6 +125,22 @@ export function detectRenderModeHints(html: string): RenderModeHints {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHADER_TRANSITION_USAGE_PATTERN =
|
||||||
|
/\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
|
||||||
|
|
||||||
|
export function detectShaderTransitionUsage(html: string): boolean {
|
||||||
|
let scriptMatch: RegExpExecArray | null;
|
||||||
|
const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
|
||||||
|
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
|
||||||
|
const attrs = scriptMatch[1] || "";
|
||||||
|
if (/\bsrc\s*=/i.test(attrs)) continue;
|
||||||
|
const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
|
||||||
|
if (SHADER_TRANSITION_USAGE_PATTERN.test(content)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveMediaDuration(
|
async function resolveMediaDuration(
|
||||||
src: string,
|
src: string,
|
||||||
mediaStart: number,
|
mediaStart: number,
|
||||||
@@ -932,6 +949,7 @@ export async function compileForRender(
|
|||||||
"$1",
|
"$1",
|
||||||
);
|
);
|
||||||
const renderModeHints = detectRenderModeHints(sanitizedHtml);
|
const renderModeHints = detectRenderModeHints(sanitizedHtml);
|
||||||
|
const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
|
||||||
|
|
||||||
const coalescedHtml = await injectDeterministicFontFaces(
|
const coalescedHtml = await injectDeterministicFontFaces(
|
||||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
||||||
@@ -1014,6 +1032,7 @@ export async function compileForRender(
|
|||||||
height,
|
height,
|
||||||
staticDuration,
|
staticDuration,
|
||||||
renderModeHints,
|
renderModeHints,
|
||||||
|
hasShaderTransitions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,5 +1208,6 @@ export async function recompileWithResolutions(
|
|||||||
images,
|
images,
|
||||||
unresolvedCompositions: remaining,
|
unresolvedCompositions: remaining,
|
||||||
renderModeHints: compiled.renderModeHints,
|
renderModeHints: compiled.renderModeHints,
|
||||||
|
hasShaderTransitions: compiled.hasShaderTransitions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ import type { CompiledComposition } from "./htmlCompiler.js";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyRenderModeHints,
|
applyRenderModeHints,
|
||||||
|
buildMissingFrameRetryBatches,
|
||||||
|
createCaptureCalibrationConfig,
|
||||||
|
estimateMeasuredCaptureCostMultiplier,
|
||||||
|
estimateCaptureCostMultiplier,
|
||||||
extractStandaloneEntryFromIndex,
|
extractStandaloneEntryFromIndex,
|
||||||
|
findMissingFrameRanges,
|
||||||
|
getNextRetryWorkerCount,
|
||||||
|
isRecoverableParallelCaptureError,
|
||||||
projectBrowserEndToCompositionTimeline,
|
projectBrowserEndToCompositionTimeline,
|
||||||
|
resolveRenderWorkerCount,
|
||||||
|
selectCaptureCalibrationFrames,
|
||||||
|
shouldFallbackToScreenshotAfterCalibrationError,
|
||||||
writeCompiledArtifacts,
|
writeCompiledArtifacts,
|
||||||
} from "./renderOrchestrator.js";
|
} from "./renderOrchestrator.js";
|
||||||
import { toExternalAssetKey } from "../utils/paths.js";
|
import { toExternalAssetKey } from "../utils/paths.js";
|
||||||
@@ -119,6 +129,7 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
|
|||||||
recommendScreenshot: false,
|
recommendScreenshot: false,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
},
|
},
|
||||||
|
hasShaderTransitions: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeCompiledArtifacts(compiled, workDir, false);
|
writeCompiledArtifacts(compiled, workDir, false);
|
||||||
@@ -150,6 +161,7 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
|
|||||||
recommendScreenshot: false,
|
recommendScreenshot: false,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
},
|
},
|
||||||
|
hasShaderTransitions: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeCompiledArtifacts(compiled, workDir, false);
|
writeCompiledArtifacts(compiled, workDir, false);
|
||||||
@@ -159,60 +171,61 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createCompiledComposition(
|
||||||
|
reasonCodes: Array<"iframe" | "requestAnimationFrame">,
|
||||||
|
): CompiledComposition {
|
||||||
|
return {
|
||||||
|
html: "<html></html>",
|
||||||
|
subCompositions: new Map(),
|
||||||
|
videos: [],
|
||||||
|
audios: [],
|
||||||
|
unresolvedCompositions: [],
|
||||||
|
externalAssets: new Map(),
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
staticDuration: 5,
|
||||||
|
renderModeHints: {
|
||||||
|
recommendScreenshot: reasonCodes.length > 0,
|
||||||
|
reasons: reasonCodes.map((code) => ({
|
||||||
|
code,
|
||||||
|
message: `reason: ${code}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
hasShaderTransitions: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfig(): EngineConfig {
|
||||||
|
return {
|
||||||
|
fps: 30,
|
||||||
|
quality: "standard",
|
||||||
|
format: "jpeg",
|
||||||
|
jpegQuality: 80,
|
||||||
|
concurrency: "auto",
|
||||||
|
coresPerWorker: 2.5,
|
||||||
|
minParallelFrames: 120,
|
||||||
|
largeRenderThreshold: 1000,
|
||||||
|
disableGpu: false,
|
||||||
|
enableBrowserPool: false,
|
||||||
|
browserTimeout: 120000,
|
||||||
|
protocolTimeout: 300000,
|
||||||
|
forceScreenshot: false,
|
||||||
|
enableChunkedEncode: false,
|
||||||
|
chunkSizeFrames: 360,
|
||||||
|
enableStreamingEncode: false,
|
||||||
|
ffmpegEncodeTimeout: 600000,
|
||||||
|
ffmpegProcessTimeout: 300000,
|
||||||
|
ffmpegStreamingTimeout: 600000,
|
||||||
|
audioGain: 1,
|
||||||
|
frameDataUriCacheLimit: 256,
|
||||||
|
playerReadyTimeout: 45000,
|
||||||
|
renderReadyTimeout: 15000,
|
||||||
|
verifyRuntime: true,
|
||||||
|
debug: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("applyRenderModeHints", () => {
|
describe("applyRenderModeHints", () => {
|
||||||
function createCompiledComposition(
|
|
||||||
reasonCodes: Array<"iframe" | "requestAnimationFrame">,
|
|
||||||
): CompiledComposition {
|
|
||||||
return {
|
|
||||||
html: "<html></html>",
|
|
||||||
subCompositions: new Map(),
|
|
||||||
videos: [],
|
|
||||||
audios: [],
|
|
||||||
unresolvedCompositions: [],
|
|
||||||
externalAssets: new Map(),
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
staticDuration: 5,
|
|
||||||
renderModeHints: {
|
|
||||||
recommendScreenshot: reasonCodes.length > 0,
|
|
||||||
reasons: reasonCodes.map((code) => ({
|
|
||||||
code,
|
|
||||||
message: `reason: ${code}`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createConfig(): EngineConfig {
|
|
||||||
return {
|
|
||||||
fps: 30,
|
|
||||||
quality: "standard",
|
|
||||||
format: "jpeg",
|
|
||||||
jpegQuality: 80,
|
|
||||||
concurrency: "auto",
|
|
||||||
coresPerWorker: 2.5,
|
|
||||||
minParallelFrames: 120,
|
|
||||||
largeRenderThreshold: 1000,
|
|
||||||
disableGpu: false,
|
|
||||||
enableBrowserPool: false,
|
|
||||||
browserTimeout: 120000,
|
|
||||||
protocolTimeout: 300000,
|
|
||||||
forceScreenshot: false,
|
|
||||||
enableChunkedEncode: false,
|
|
||||||
chunkSizeFrames: 360,
|
|
||||||
enableStreamingEncode: false,
|
|
||||||
ffmpegEncodeTimeout: 600000,
|
|
||||||
ffmpegProcessTimeout: 300000,
|
|
||||||
ffmpegStreamingTimeout: 600000,
|
|
||||||
audioGain: 1,
|
|
||||||
frameDataUriCacheLimit: 256,
|
|
||||||
playerReadyTimeout: 45000,
|
|
||||||
renderReadyTimeout: 15000,
|
|
||||||
verifyRuntime: true,
|
|
||||||
debug: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("forces screenshot mode when compatibility hints recommend it", () => {
|
it("forces screenshot mode when compatibility hints recommend it", () => {
|
||||||
const cfg = createConfig();
|
const cfg = createConfig();
|
||||||
const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]);
|
const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]);
|
||||||
@@ -246,6 +259,246 @@ describe("applyRenderModeHints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveRenderWorkerCount", () => {
|
||||||
|
const cfg = { ...createConfig(), coresPerWorker: 100 };
|
||||||
|
const audio = {
|
||||||
|
id: "narration",
|
||||||
|
src: "narration.wav",
|
||||||
|
start: 0,
|
||||||
|
end: 3,
|
||||||
|
mediaStart: 0,
|
||||||
|
layer: 9,
|
||||||
|
type: "audio" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reduces auto workers for expensive capture workloads", () => {
|
||||||
|
const log = {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const workers = resolveRenderWorkerCount(
|
||||||
|
180,
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
hasShaderTransitions: true,
|
||||||
|
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||||||
|
},
|
||||||
|
{ videos: [], audios: [audio] },
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workers).toBe(1);
|
||||||
|
expect(log.warn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects explicit worker requests", () => {
|
||||||
|
const log = {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const workers = resolveRenderWorkerCount(
|
||||||
|
180,
|
||||||
|
6,
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
hasShaderTransitions: true,
|
||||||
|
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||||||
|
},
|
||||||
|
{ videos: [], audios: [audio] },
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workers).toBe(6);
|
||||||
|
expect(log.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses measured capture cost when static hints miss an expensive composition", () => {
|
||||||
|
const workers = resolveRenderWorkerCount(
|
||||||
|
180,
|
||||||
|
undefined,
|
||||||
|
cfg,
|
||||||
|
{
|
||||||
|
hasShaderTransitions: false,
|
||||||
|
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||||||
|
},
|
||||||
|
{ videos: [], audios: [] },
|
||||||
|
undefined,
|
||||||
|
{ multiplier: 4, reasons: ["calibration-p95=2400ms"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(workers).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("estimateCaptureCostMultiplier", () => {
|
||||||
|
it("weights shader transitions, media, and render mode hints", () => {
|
||||||
|
const cost = estimateCaptureCostMultiplier(
|
||||||
|
{
|
||||||
|
hasShaderTransitions: true,
|
||||||
|
renderModeHints: {
|
||||||
|
recommendScreenshot: true,
|
||||||
|
reasons: [{ code: "requestAnimationFrame", message: "raw rAF" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
videos: [],
|
||||||
|
audios: [
|
||||||
|
{
|
||||||
|
id: "narration",
|
||||||
|
src: "narration.wav",
|
||||||
|
start: 0,
|
||||||
|
end: 3,
|
||||||
|
mediaStart: 0,
|
||||||
|
layer: 9,
|
||||||
|
type: "audio" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cost.multiplier).toBe(4.75);
|
||||||
|
expect(cost.reasons).toEqual(["shader-transitions", "requestAnimationFrame", "1 audio"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("estimateMeasuredCaptureCostMultiplier", () => {
|
||||||
|
it("turns slow calibration samples into a capture cost multiplier", () => {
|
||||||
|
const estimate = estimateMeasuredCaptureCostMultiplier([
|
||||||
|
{ frameIndex: 0, captureTimeMs: 180 },
|
||||||
|
{ frameIndex: 45, captureTimeMs: 700 },
|
||||||
|
{ frameIndex: 90, captureTimeMs: 2400 },
|
||||||
|
{ frameIndex: 135, captureTimeMs: 900 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(estimate.multiplier).toBe(4);
|
||||||
|
expect(estimate.reasons).toEqual(["calibration-p95=2400ms"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps fast calibration samples at baseline cost", () => {
|
||||||
|
const estimate = estimateMeasuredCaptureCostMultiplier([
|
||||||
|
{ frameIndex: 0, captureTimeMs: 120 },
|
||||||
|
{ frameIndex: 60, captureTimeMs: 180 },
|
||||||
|
{ frameIndex: 119, captureTimeMs: 220 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(estimate.multiplier).toBe(1);
|
||||||
|
expect(estimate.reasons).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectCaptureCalibrationFrames", () => {
|
||||||
|
it("samples the start, middle, end, and quartiles without duplicates", () => {
|
||||||
|
expect(selectCaptureCalibrationFrames(180)).toEqual([0, 45, 90, 135, 179]);
|
||||||
|
expect(selectCaptureCalibrationFrames(3)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("capture calibration safeguards", () => {
|
||||||
|
it("uses a bounded protocol timeout for calibration probes", () => {
|
||||||
|
const cfg = createConfig();
|
||||||
|
const calibrationCfg = createCaptureCalibrationConfig(cfg);
|
||||||
|
|
||||||
|
expect(calibrationCfg.protocolTimeout).toBe(30000);
|
||||||
|
expect(cfg.protocolTimeout).toBe(300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves smaller explicit protocol timeouts for calibration probes", () => {
|
||||||
|
const cfg = createConfig();
|
||||||
|
cfg.protocolTimeout = 5000;
|
||||||
|
|
||||||
|
expect(createCaptureCalibrationConfig(cfg).protocolTimeout).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to screenshot mode after beginFrame calibration failures", () => {
|
||||||
|
expect(
|
||||||
|
shouldFallbackToScreenshotAfterCalibrationError(
|
||||||
|
new Error("HeadlessExperimental.beginFrame timed out"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(shouldFallbackToScreenshotAfterCalibrationError(new Error("ffmpeg exited"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adaptive missing-frame retry helpers", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const d = tempDirs.pop();
|
||||||
|
if (d) rmSync(d, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFramesDir(): string {
|
||||||
|
const d = mkdtempSync(join(tmpdir(), "hf-missing-frames-"));
|
||||||
|
tempDirs.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("finds contiguous missing frame ranges from captured disk frames", () => {
|
||||||
|
const framesDir = makeFramesDir();
|
||||||
|
for (const frameIndex of [0, 1, 4]) {
|
||||||
|
writeFileSync(join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.jpg`), "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(findMissingFrameRanges(6, framesDir, "jpg")).toEqual([
|
||||||
|
{ startFrame: 2, endFrame: 4 },
|
||||||
|
{ startFrame: 5, endFrame: 6 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds retry batches that cap active workers per attempt", () => {
|
||||||
|
const batches = buildMissingFrameRetryBatches(
|
||||||
|
[
|
||||||
|
{ startFrame: 2, endFrame: 4 },
|
||||||
|
{ startFrame: 5, endFrame: 6 },
|
||||||
|
{ startFrame: 9, endFrame: 12 },
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
"/tmp/work",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(batches).toHaveLength(2);
|
||||||
|
expect(batches[0]).toMatchObject([
|
||||||
|
{ workerId: 0, startFrame: 2, endFrame: 4 },
|
||||||
|
{ workerId: 1, startFrame: 5, endFrame: 6 },
|
||||||
|
]);
|
||||||
|
expect(batches[1]).toMatchObject([{ workerId: 0, startFrame: 9, endFrame: 12 }]);
|
||||||
|
expect(batches[0][0].outputDir).toContain("retry-1-batch-0-worker-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("halves retry workers until sequential fallback", () => {
|
||||||
|
expect(getNextRetryWorkerCount(8)).toBe(4);
|
||||||
|
expect(getNextRetryWorkerCount(3)).toBe(1);
|
||||||
|
expect(getNextRetryWorkerCount(2)).toBe(1);
|
||||||
|
expect(getNextRetryWorkerCount(1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only retries parallel capture timeout failures", () => {
|
||||||
|
expect(
|
||||||
|
isRecoverableParallelCaptureError(
|
||||||
|
new Error("[Parallel] Capture failed: Worker 0: Runtime.callFunctionOn timed out"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isRecoverableParallelCaptureError(
|
||||||
|
new Error("[Parallel] Capture failed: Worker 1: HeadlessExperimental.beginFrame timed out"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isRecoverableParallelCaptureError(new Error("Encoding failed: ffmpeg exited"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("projectBrowserEndToCompositionTimeline", () => {
|
describe("projectBrowserEndToCompositionTimeline", () => {
|
||||||
it("keeps end unchanged when browser and compiled starts share the same origin", () => {
|
it("keeps end unchanged when browser and compiled starts share the same origin", () => {
|
||||||
expect(projectBrowserEndToCompositionTimeline(2, 2, 6)).toBe(6);
|
expect(projectBrowserEndToCompositionTimeline(2, 2, 6)).toBe(6);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import {
|
|||||||
distributeFrames,
|
distributeFrames,
|
||||||
executeParallelCapture,
|
executeParallelCapture,
|
||||||
mergeWorkerFrames,
|
mergeWorkerFrames,
|
||||||
|
type ParallelProgress,
|
||||||
|
type WorkerTask,
|
||||||
spawnStreamingEncoder,
|
spawnStreamingEncoder,
|
||||||
createFrameReorderBuffer,
|
createFrameReorderBuffer,
|
||||||
type StreamingEncoder,
|
type StreamingEncoder,
|
||||||
@@ -278,6 +280,13 @@ export interface RenderPerfSummary {
|
|||||||
tmpPeakBytes?: number;
|
tmpPeakBytes?: number;
|
||||||
captureAvgMs?: number;
|
captureAvgMs?: number;
|
||||||
capturePeakMs?: number;
|
capturePeakMs?: number;
|
||||||
|
captureCalibration?: {
|
||||||
|
sampledFrames: number[];
|
||||||
|
p95Ms?: number;
|
||||||
|
multiplier: number;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
captureAttempts?: CaptureAttemptSummary[];
|
||||||
/**
|
/**
|
||||||
* Peak resident set size (RSS) observed during the render, in MiB.
|
* Peak resident set size (RSS) observed during the render, in MiB.
|
||||||
*
|
*
|
||||||
@@ -303,6 +312,29 @@ export interface HdrDiagnostics {
|
|||||||
imageDecodeFailures: number;
|
imageDecodeFailures: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CaptureCostEstimate {
|
||||||
|
multiplier: number;
|
||||||
|
reasons: string[];
|
||||||
|
p95Ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureCalibrationSample {
|
||||||
|
frameIndex: number;
|
||||||
|
captureTimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameRange {
|
||||||
|
startFrame: number;
|
||||||
|
endFrame: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureAttemptSummary {
|
||||||
|
attempt: number;
|
||||||
|
workers: number;
|
||||||
|
frameCount: number;
|
||||||
|
reason: "initial" | "retry";
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderJob {
|
export interface RenderJob {
|
||||||
id: string;
|
id: string;
|
||||||
config: RenderConfig;
|
config: RenderConfig;
|
||||||
@@ -480,6 +512,7 @@ export function writeCompiledArtifacts(
|
|||||||
})),
|
})),
|
||||||
subCompositions: Array.from(compiled.subCompositions.keys()),
|
subCompositions: Array.from(compiled.subCompositions.keys()),
|
||||||
renderModeHints: compiled.renderModeHints,
|
renderModeHints: compiled.renderModeHints,
|
||||||
|
hasShaderTransitions: compiled.hasShaderTransitions,
|
||||||
};
|
};
|
||||||
writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
||||||
}
|
}
|
||||||
@@ -499,6 +532,383 @@ export function applyRenderModeHints(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRenderWorkerCount(
|
||||||
|
totalFrames: number,
|
||||||
|
requestedWorkers: number | undefined,
|
||||||
|
cfg: EngineConfig,
|
||||||
|
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
|
||||||
|
composition: Pick<CompositionMetadata, "videos" | "audios">,
|
||||||
|
log: ProducerLogger = defaultLogger,
|
||||||
|
measuredCaptureCost?: CaptureCostEstimate,
|
||||||
|
): number {
|
||||||
|
const captureCost = combineCaptureCostEstimates(
|
||||||
|
estimateCaptureCostMultiplier(compiled, composition),
|
||||||
|
measuredCaptureCost,
|
||||||
|
);
|
||||||
|
const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
|
||||||
|
...cfg,
|
||||||
|
captureCostMultiplier: captureCost.multiplier,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestedWorkers !== undefined || captureCost.multiplier <= 1) {
|
||||||
|
return workerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineWorkers = calculateOptimalWorkers(totalFrames, undefined, cfg);
|
||||||
|
if (workerCount < baselineWorkers) {
|
||||||
|
log.warn(
|
||||||
|
"[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
|
||||||
|
{
|
||||||
|
from: baselineWorkers,
|
||||||
|
to: workerCount,
|
||||||
|
costMultiplier: captureCost.multiplier,
|
||||||
|
reasons: captureCost.reasons,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateCaptureCostMultiplier(
|
||||||
|
compiled: Pick<CompiledComposition, "hasShaderTransitions" | "renderModeHints">,
|
||||||
|
composition: Pick<CompositionMetadata, "videos" | "audios">,
|
||||||
|
): CaptureCostEstimate {
|
||||||
|
let multiplier = 1;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
if (compiled.hasShaderTransitions) {
|
||||||
|
multiplier += 2;
|
||||||
|
reasons.push("shader-transitions");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
|
||||||
|
if (reasonCodes.has("requestAnimationFrame")) {
|
||||||
|
multiplier += 1;
|
||||||
|
reasons.push("requestAnimationFrame");
|
||||||
|
}
|
||||||
|
if (reasonCodes.has("iframe")) {
|
||||||
|
multiplier += 0.5;
|
||||||
|
reasons.push("iframe");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composition.videos.length > 0) {
|
||||||
|
multiplier += Math.min(2, composition.videos.length * 0.75);
|
||||||
|
reasons.push(`${composition.videos.length} video${composition.videos.length === 1 ? "" : "s"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composition.audios.length > 0) {
|
||||||
|
multiplier += Math.min(1, composition.audios.length * 0.75);
|
||||||
|
reasons.push(`${composition.audios.length} audio${composition.audios.length === 1 ? "" : "s"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
multiplier: Math.round(multiplier * 100) / 100,
|
||||||
|
reasons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineCaptureCostEstimates(
|
||||||
|
staticCost: CaptureCostEstimate,
|
||||||
|
measuredCost?: CaptureCostEstimate,
|
||||||
|
): CaptureCostEstimate {
|
||||||
|
if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
|
||||||
|
if (staticCost.multiplier >= measuredCost.multiplier) {
|
||||||
|
return {
|
||||||
|
multiplier: staticCost.multiplier,
|
||||||
|
reasons: [...staticCost.reasons, ...measuredCost.reasons],
|
||||||
|
p95Ms: measuredCost.p95Ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
multiplier: measuredCost.multiplier,
|
||||||
|
reasons: [...measuredCost.reasons, ...staticCost.reasons],
|
||||||
|
p95Ms: measuredCost.p95Ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAPTURE_CALIBRATION_TARGET_MS = 600;
|
||||||
|
const MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
|
||||||
|
const CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export function createCaptureCalibrationConfig(cfg: EngineConfig): EngineConfig {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateMeasuredCaptureCostMultiplier(
|
||||||
|
samples: CaptureCalibrationSample[],
|
||||||
|
): CaptureCostEstimate {
|
||||||
|
if (samples.length === 0) {
|
||||||
|
return { multiplier: 1, reasons: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
|
||||||
|
const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
|
||||||
|
const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
|
||||||
|
if (!p95Sample) {
|
||||||
|
return { multiplier: 1, reasons: [] };
|
||||||
|
}
|
||||||
|
const p95Ms = Math.round(p95Sample.captureTimeMs);
|
||||||
|
const multiplier = Math.min(
|
||||||
|
MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
|
||||||
|
Math.max(1, Math.round((p95Ms / CAPTURE_CALIBRATION_TARGET_MS) * 100) / 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
multiplier,
|
||||||
|
reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
|
||||||
|
p95Ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectCaptureCalibrationFrames(totalFrames: number): number[] {
|
||||||
|
if (totalFrames <= 0) return [];
|
||||||
|
const lastFrame = totalFrames - 1;
|
||||||
|
const candidates = [
|
||||||
|
0,
|
||||||
|
Math.floor(totalFrames * 0.25),
|
||||||
|
Math.floor(totalFrames * 0.5),
|
||||||
|
Math.floor(totalFrames * 0.75),
|
||||||
|
lastFrame,
|
||||||
|
];
|
||||||
|
return Array.from(
|
||||||
|
new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame)))),
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMissingFrameRanges(
|
||||||
|
totalFrames: number,
|
||||||
|
framesDir: string,
|
||||||
|
frameExt: "jpg" | "png",
|
||||||
|
): FrameRange[] {
|
||||||
|
const ranges: FrameRange[] = [];
|
||||||
|
let rangeStart: number | null = null;
|
||||||
|
|
||||||
|
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
||||||
|
const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
|
||||||
|
const missing = !existsSync(framePath);
|
||||||
|
if (missing && rangeStart === null) {
|
||||||
|
rangeStart = frameIndex;
|
||||||
|
} else if (!missing && rangeStart !== null) {
|
||||||
|
ranges.push({ startFrame: rangeStart, endFrame: frameIndex });
|
||||||
|
rangeStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeStart !== null) {
|
||||||
|
ranges.push({ startFrame: rangeStart, endFrame: totalFrames });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMissingFrameRetryBatches(
|
||||||
|
ranges: FrameRange[],
|
||||||
|
maxWorkers: number,
|
||||||
|
workDir: string,
|
||||||
|
attempt: number,
|
||||||
|
): WorkerTask[][] {
|
||||||
|
const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
|
||||||
|
const batches: WorkerTask[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < ranges.length; i += workersPerBatch) {
|
||||||
|
const batchIndex = batches.length;
|
||||||
|
const batch = ranges.slice(i, i + workersPerBatch).map((range, workerId) => ({
|
||||||
|
workerId,
|
||||||
|
startFrame: range.startFrame,
|
||||||
|
endFrame: range.endFrame,
|
||||||
|
outputDir: join(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
|
||||||
|
}));
|
||||||
|
batches.push(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextRetryWorkerCount(currentWorkers: number): number {
|
||||||
|
return Math.max(1, Math.floor(currentWorkers / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecoverableParallelCaptureError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes("[Parallel] Capture failed") &&
|
||||||
|
/Runtime\.callFunctionOn timed out|HeadlessExperimental\.beginFrame timed out|Waiting failed|timeout exceeded|timed out|Navigation timeout|Protocol error|Target closed/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldFallbackToScreenshotAfterCalibrationError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame/i.test(
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCapturedFrames(
|
||||||
|
totalFrames: number,
|
||||||
|
framesDir: string,
|
||||||
|
frameExt: "jpg" | "png",
|
||||||
|
): number {
|
||||||
|
let captured = 0;
|
||||||
|
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
||||||
|
const framePath = join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
|
||||||
|
if (existsSync(framePath)) captured++;
|
||||||
|
}
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countFrameRanges(ranges: FrameRange[]): number {
|
||||||
|
return ranges.reduce((sum, range) => sum + (range.endFrame - range.startFrame), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureCaptureCostFromSession(
|
||||||
|
session: CaptureSession,
|
||||||
|
totalFrames: number,
|
||||||
|
fps: number,
|
||||||
|
): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> {
|
||||||
|
const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
|
||||||
|
const samples: CaptureCalibrationSample[] = [];
|
||||||
|
|
||||||
|
for (const frameIndex of sampledFrames) {
|
||||||
|
const time = frameIndex / fps;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result = await captureFrameToBuffer(session, frameIndex, time);
|
||||||
|
samples.push({
|
||||||
|
frameIndex,
|
||||||
|
captureTimeMs: result.captureTimeMs || Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
estimate: estimateMeasuredCaptureCostMultiplier(samples),
|
||||||
|
samples,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDiskCaptureWithAdaptiveRetry(options: {
|
||||||
|
serverUrl: string;
|
||||||
|
workDir: string;
|
||||||
|
framesDir: string;
|
||||||
|
totalFrames: number;
|
||||||
|
initialWorkerCount: number;
|
||||||
|
allowRetry: boolean;
|
||||||
|
frameExt: "jpg" | "png";
|
||||||
|
captureOptions: CaptureOptions;
|
||||||
|
createBeforeCaptureHook: () => BeforeCaptureHook | null;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
onProgress?: (progress: ParallelProgress) => void;
|
||||||
|
cfg: EngineConfig;
|
||||||
|
log: ProducerLogger;
|
||||||
|
}): Promise<CaptureAttemptSummary[]> {
|
||||||
|
const attempts: CaptureAttemptSummary[] = [];
|
||||||
|
let currentWorkers = options.initialWorkerCount;
|
||||||
|
let missingRanges: FrameRange[] | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const frameCount = missingRanges ? countFrameRanges(missingRanges) : options.totalFrames;
|
||||||
|
attempts.push({
|
||||||
|
attempt,
|
||||||
|
workers: currentWorkers,
|
||||||
|
frameCount,
|
||||||
|
reason: attempt === 0 ? "initial" : "retry",
|
||||||
|
});
|
||||||
|
|
||||||
|
const attemptWorkDir = join(options.workDir, `capture-attempt-${attempt}`);
|
||||||
|
const batches = missingRanges
|
||||||
|
? buildMissingFrameRetryBatches(missingRanges, currentWorkers, attemptWorkDir, attempt)
|
||||||
|
: [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir)];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const tasks of batches) {
|
||||||
|
const capturedBeforeBatch = countCapturedFrames(
|
||||||
|
options.totalFrames,
|
||||||
|
options.framesDir,
|
||||||
|
options.frameExt,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await executeParallelCapture(
|
||||||
|
options.serverUrl,
|
||||||
|
attemptWorkDir,
|
||||||
|
tasks,
|
||||||
|
options.captureOptions,
|
||||||
|
options.createBeforeCaptureHook,
|
||||||
|
options.abortSignal,
|
||||||
|
options.onProgress
|
||||||
|
? (progress) => {
|
||||||
|
options.onProgress?.({
|
||||||
|
...progress,
|
||||||
|
totalFrames: options.totalFrames,
|
||||||
|
capturedFrames: Math.min(
|
||||||
|
options.totalFrames,
|
||||||
|
capturedBeforeBatch + progress.capturedFrames,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
undefined,
|
||||||
|
options.cfg,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = findMissingFrameRanges(
|
||||||
|
options.totalFrames,
|
||||||
|
options.framesDir,
|
||||||
|
options.frameExt,
|
||||||
|
);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
if (!options.allowRetry || currentWorkers <= 1) {
|
||||||
|
throw new Error(
|
||||||
|
`[Render] Capture completed but ${countFrameRanges(remaining)} frame(s) are missing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWorkers = getNextRetryWorkerCount(currentWorkers);
|
||||||
|
options.log.warn("[Render] Retrying missing captured frames with fewer workers.", {
|
||||||
|
fromWorkers: currentWorkers,
|
||||||
|
toWorkers: nextWorkers,
|
||||||
|
missingFrames: countFrameRanges(remaining),
|
||||||
|
});
|
||||||
|
currentWorkers = nextWorkers;
|
||||||
|
missingRanges = remaining;
|
||||||
|
attempt++;
|
||||||
|
} catch (error) {
|
||||||
|
const remaining = findMissingFrameRanges(
|
||||||
|
options.totalFrames,
|
||||||
|
options.framesDir,
|
||||||
|
options.frameExt,
|
||||||
|
);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
if (!options.allowRetry || currentWorkers <= 1 || !isRecoverableParallelCaptureError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWorkers = getNextRetryWorkerCount(currentWorkers);
|
||||||
|
options.log.warn("[Render] Parallel capture timed out; retrying missing frames.", {
|
||||||
|
fromWorkers: currentWorkers,
|
||||||
|
toWorkers: nextWorkers,
|
||||||
|
missingFrames: countFrameRanges(remaining),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
currentWorkers = nextWorkers;
|
||||||
|
missingRanges = remaining;
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blit a single HDR video layer onto an rgb48le canvas.
|
* Blit a single HDR video layer onto an rgb48le canvas.
|
||||||
*
|
*
|
||||||
@@ -1607,7 +2017,113 @@ export async function executeRenderJob(
|
|||||||
skipReadinessVideoIds: Array.from(nativeHdrVideoIds),
|
skipReadinessVideoIds: Array.from(nativeHdrVideoIds),
|
||||||
});
|
});
|
||||||
|
|
||||||
const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
|
let captureCalibration:
|
||||||
|
| {
|
||||||
|
estimate: CaptureCostEstimate;
|
||||||
|
samples: CaptureCalibrationSample[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let switchedToScreenshotAfterCalibration = false;
|
||||||
|
|
||||||
|
if (job.config.workers === undefined && totalFrames >= 60) {
|
||||||
|
const calibrationDir = join(workDir, "capture-calibration");
|
||||||
|
const calibrationCfg = createCaptureCalibrationConfig(cfg);
|
||||||
|
const videoInjector = createVideoFrameInjector(frameLookup);
|
||||||
|
let calibrationSession: CaptureSession | null = null;
|
||||||
|
try {
|
||||||
|
calibrationSession = await createCaptureSession(
|
||||||
|
fileServer.url,
|
||||||
|
calibrationDir,
|
||||||
|
buildHdrCaptureOptions(),
|
||||||
|
videoInjector,
|
||||||
|
calibrationCfg,
|
||||||
|
);
|
||||||
|
if (!calibrationSession.isInitialized) {
|
||||||
|
await initializeSession(calibrationSession);
|
||||||
|
}
|
||||||
|
assertNotAborted();
|
||||||
|
|
||||||
|
captureCalibration = await measureCaptureCostFromSession(
|
||||||
|
calibrationSession,
|
||||||
|
totalFrames,
|
||||||
|
job.config.fps,
|
||||||
|
);
|
||||||
|
if (captureCalibration.estimate.multiplier > 1) {
|
||||||
|
log.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
|
||||||
|
multiplier: captureCalibration.estimate.multiplier,
|
||||||
|
p95Ms: captureCalibration.estimate.p95Ms,
|
||||||
|
sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
|
||||||
|
p95Ms: captureCalibration.estimate.p95Ms,
|
||||||
|
sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const shouldFallbackToScreenshot =
|
||||||
|
!cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
|
||||||
|
if (shouldFallbackToScreenshot) {
|
||||||
|
cfg.forceScreenshot = true;
|
||||||
|
switchedToScreenshotAfterCalibration = true;
|
||||||
|
if (probeSession) {
|
||||||
|
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||||
|
await closeCaptureSession(probeSession).catch(() => {});
|
||||||
|
probeSession = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captureCalibration = {
|
||||||
|
estimate: {
|
||||||
|
multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
|
||||||
|
reasons: shouldFallbackToScreenshot
|
||||||
|
? ["calibration-beginframe-timeout", "screenshot-fallback"]
|
||||||
|
: ["calibration-failed"],
|
||||||
|
},
|
||||||
|
samples: [],
|
||||||
|
};
|
||||||
|
if (shouldFallbackToScreenshot) {
|
||||||
|
log.warn(
|
||||||
|
"[Render] BeginFrame auto-worker calibration timed out; falling back to screenshot capture mode.",
|
||||||
|
{
|
||||||
|
protocolTimeout: calibrationCfg.protocolTimeout,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
|
||||||
|
protocolTimeout: calibrationCfg.protocolTimeout,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (calibrationSession) {
|
||||||
|
lastBrowserConsole = calibrationSession.browserConsoleBuffer;
|
||||||
|
await closeCaptureSession(calibrationSession).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let workerCount = resolveRenderWorkerCount(
|
||||||
|
totalFrames,
|
||||||
|
job.config.workers,
|
||||||
|
cfg,
|
||||||
|
compiled,
|
||||||
|
composition,
|
||||||
|
log,
|
||||||
|
captureCalibration?.estimate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (switchedToScreenshotAfterCalibration && workerCount > 1) {
|
||||||
|
workerCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workerCount > 1 && probeSession) {
|
||||||
|
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||||
|
await closeCaptureSession(probeSession);
|
||||||
|
probeSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureAttempts: CaptureAttemptSummary[] = [];
|
||||||
|
|
||||||
// png-sequence is "no container" — outputPath is treated as a directory and
|
// png-sequence is "no container" — outputPath is treated as a directory and
|
||||||
// the encode/mux/faststart stages are skipped entirely. The empty extension
|
// the encode/mux/faststart stages are skipped entirely. The empty extension
|
||||||
@@ -2500,16 +3016,18 @@ export async function executeRenderJob(
|
|||||||
// ── Disk-based capture (original flow) ────────────────────────────
|
// ── Disk-based capture (original flow) ────────────────────────────
|
||||||
if (workerCount > 1) {
|
if (workerCount > 1) {
|
||||||
// Parallel capture
|
// Parallel capture
|
||||||
const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
|
const attempts = await executeDiskCaptureWithAdaptiveRetry({
|
||||||
|
serverUrl: fileServer.url,
|
||||||
await executeParallelCapture(
|
|
||||||
fileServer.url,
|
|
||||||
workDir,
|
workDir,
|
||||||
tasks,
|
framesDir,
|
||||||
buildHdrCaptureOptions(),
|
totalFrames: job.totalFrames,
|
||||||
() => createVideoFrameInjector(frameLookup),
|
initialWorkerCount: workerCount,
|
||||||
|
allowRetry: job.config.workers === undefined,
|
||||||
|
frameExt: needsAlpha ? "png" : "jpg",
|
||||||
|
captureOptions: buildHdrCaptureOptions(),
|
||||||
|
createBeforeCaptureHook: () => createVideoFrameInjector(frameLookup),
|
||||||
abortSignal,
|
abortSignal,
|
||||||
(progress) => {
|
onProgress: (progress) => {
|
||||||
job.framesRendered = progress.capturedFrames;
|
job.framesRendered = progress.capturedFrames;
|
||||||
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
const frameProgress = progress.capturedFrames / progress.totalFrames;
|
||||||
const progressPct = 25 + frameProgress * 45;
|
const progressPct = 25 + frameProgress * 45;
|
||||||
@@ -2521,17 +3039,20 @@ export async function executeRenderJob(
|
|||||||
updateJobStatus(
|
updateJobStatus(
|
||||||
job,
|
job,
|
||||||
"rendering",
|
"rendering",
|
||||||
`Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
|
`Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
|
||||||
Math.round(progressPct),
|
Math.round(progressPct),
|
||||||
onProgress,
|
onProgress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
|
||||||
cfg,
|
cfg,
|
||||||
);
|
log,
|
||||||
|
});
|
||||||
await mergeWorkerFrames(workDir, tasks, framesDir);
|
captureAttempts.push(...attempts);
|
||||||
|
const lastAttempt = attempts[attempts.length - 1];
|
||||||
|
if (lastAttempt) {
|
||||||
|
workerCount = lastAttempt.workers;
|
||||||
|
}
|
||||||
if (probeSession) {
|
if (probeSession) {
|
||||||
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
lastBrowserConsole = probeSession.browserConsoleBuffer;
|
||||||
await closeCaptureSession(probeSession);
|
await closeCaptureSession(probeSession);
|
||||||
@@ -2747,6 +3268,15 @@ export async function executeRenderJob(
|
|||||||
stages: perfStages,
|
stages: perfStages,
|
||||||
videoExtractBreakdown: extractionResult?.phaseBreakdown,
|
videoExtractBreakdown: extractionResult?.phaseBreakdown,
|
||||||
tmpPeakBytes,
|
tmpPeakBytes,
|
||||||
|
captureCalibration: captureCalibration
|
||||||
|
? {
|
||||||
|
sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
|
||||||
|
p95Ms: captureCalibration.estimate.p95Ms,
|
||||||
|
multiplier: captureCalibration.estimate.multiplier,
|
||||||
|
reasons: captureCalibration.estimate.reasons,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
captureAttempts: captureAttempts.length > 0 ? captureAttempts : undefined,
|
||||||
hdrDiagnostics:
|
hdrDiagnostics:
|
||||||
hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0
|
hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0
|
||||||
? { ...hdrDiagnostics }
|
? { ...hdrDiagnostics }
|
||||||
|
|||||||
Reference in New Issue
Block a user