mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
Replaces the rigid `--fps 24|30|60` whitelist with a numeric range and
adds support for ffmpeg-style fractional framerates so NTSC stays exact
end-to-end.
- `--fps 30` keeps working (integer fps)
- `--fps 30000/1001` now means exact NTSC 29.97 (not the lossy decimal)
- `--fps 24000/1001`, `--fps 60000/1001`, `--fps 25/50/120/240` all work
- Decimals like `--fps 29.97` are rejected with a friendly error pointing
the user at the rational form, since `29.97` and `30000/1001` round
to different framerates inside ffmpeg
Carries an `Fps = { num: number; den: number }` rational end-to-end:
RenderConfig, EncoderOptions, StreamingEncoderOptions, CaptureOptions,
DockerRenderOptions, Studio API request body, regression-harness
meta.json. The `-r` and `-framerate` ffmpeg args emit the rational form
verbatim (`30000/1001`) so no decimal round-trip happens at the encoder
boundary. Frame-interval math uses `1000 * den / num` ms (33.366… for
NTSC, 33.333… for integer 30).
Helpers live in @hyperframes/core:
- `parseFps(input: string | number): FpsParseResult` — discriminated
parser used by both the CLI and the Studio API route
- `fpsToFfmpegArg(fps: Fps): string` — emits "30" or "30000/1001"
- `fpsToNumber(fps: Fps): number` — for arithmetic (telemetry, frame
count, frame-index → time)
Studio API wire format accepts polymorphic `fps: number | string`:
- number → integer fps (`30`)
- string → rational (`"30000/1001"`)
Decimals are rejected; matches the same rule as the CLI.
Existing meta.json fixtures with integer `"fps": 30` continue to load
unchanged — the regression-harness validator now normalizes both number
and string inputs through `parseFps`.
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
/**
|
|
* Parallel Coordinator Service
|
|
*
|
|
* Coordinates parallel frame capture across multiple Puppeteer sessions.
|
|
* Auto-detects optimal worker count based on CPU/memory.
|
|
*/
|
|
|
|
import { cpus, freemem, totalmem } from "os";
|
|
import { existsSync, mkdirSync, readdirSync } from "fs";
|
|
import { copyFile, rename } from "fs/promises";
|
|
import { join } from "path";
|
|
|
|
import {
|
|
createCaptureSession,
|
|
initializeSession,
|
|
closeCaptureSession,
|
|
captureFrame,
|
|
captureFrameToBuffer,
|
|
getCapturePerfSummary,
|
|
type CaptureSession,
|
|
type CaptureOptions,
|
|
type CapturePerfSummary,
|
|
type BeforeCaptureHook,
|
|
} from "./frameCapture.js";
|
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
|
|
export interface WorkerTask {
|
|
workerId: number;
|
|
startFrame: number;
|
|
endFrame: number;
|
|
outputDir: string;
|
|
}
|
|
|
|
export interface WorkerResult {
|
|
workerId: number;
|
|
framesCaptured: number;
|
|
startFrame: number;
|
|
endFrame: number;
|
|
durationMs: number;
|
|
perf?: CapturePerfSummary;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ParallelProgress {
|
|
totalFrames: number;
|
|
capturedFrames: number;
|
|
activeWorkers: 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 MIN_WORKERS = 1;
|
|
const ABSOLUTE_MAX_WORKERS = 10;
|
|
const DEFAULT_SAFE_MAX_WORKERS = 6;
|
|
const MIN_FRAMES_PER_WORKER = 30;
|
|
|
|
export function calculateOptimalWorkers(
|
|
totalFrames: number,
|
|
requested?: number,
|
|
config?: WorkerSizingConfig,
|
|
): number {
|
|
// Resolve effective values: config overrides → DEFAULT_CONFIG fallback.
|
|
const effectiveMaxWorkers = (() => {
|
|
const concurrency = config?.concurrency ?? DEFAULT_CONFIG.concurrency;
|
|
if (concurrency !== "auto") {
|
|
return Math.max(MIN_WORKERS, Math.min(ABSOLUTE_MAX_WORKERS, Math.floor(concurrency)));
|
|
}
|
|
return DEFAULT_SAFE_MAX_WORKERS;
|
|
})();
|
|
const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
|
|
const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
|
|
const effectiveLargeRenderThreshold =
|
|
config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
|
|
const captureCostMultiplier = Math.max(1, config?.captureCostMultiplier ?? 1);
|
|
|
|
if (requested !== undefined) {
|
|
return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
|
|
}
|
|
|
|
if (totalFrames < MIN_FRAMES_PER_WORKER * 2) return 1;
|
|
|
|
const cpuCount = cpus().length;
|
|
const cpuBasedWorkers = Math.max(1, cpuCount - 2);
|
|
|
|
// Use total memory instead of free memory — macOS reports misleadingly low
|
|
// freemem() because it aggressively caches files in "inactive" memory that
|
|
// is immediately reclaimable.
|
|
const totalMemoryMB = Math.round(totalmem() / (1024 * 1024));
|
|
const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
|
|
|
|
const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
|
|
|
|
const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
|
|
const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
|
|
let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
|
|
|
|
// Adaptive scaling: cap workers for large or expensive renders to prevent
|
|
// CPU contention. Each Chrome process (with SwiftShader) is CPU-heavy; too
|
|
// many concurrent captures can starve the compositor and surface as CDP
|
|
// protocol timeouts. Scale proportionally to CPU count and composition cost:
|
|
// 8 cores → 2 workers, 16 cores → 5 workers, 32 cores → 10 workers.
|
|
const weightedFrames = totalFrames * captureCostMultiplier;
|
|
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) {
|
|
finalWorkers = cpuScaledMax;
|
|
}
|
|
}
|
|
|
|
return finalWorkers;
|
|
}
|
|
|
|
export function distributeFrames(
|
|
totalFrames: number,
|
|
workerCount: number,
|
|
workDir: string,
|
|
): WorkerTask[] {
|
|
const tasks: WorkerTask[] = [];
|
|
const framesPerWorker = Math.ceil(totalFrames / workerCount);
|
|
|
|
for (let i = 0; i < workerCount; i++) {
|
|
const startFrame = i * framesPerWorker;
|
|
const endFrame = Math.min((i + 1) * framesPerWorker, totalFrames);
|
|
if (startFrame >= totalFrames) break;
|
|
|
|
tasks.push({
|
|
workerId: i,
|
|
startFrame,
|
|
endFrame,
|
|
outputDir: join(workDir, `worker-${i}`),
|
|
});
|
|
}
|
|
|
|
return tasks;
|
|
}
|
|
|
|
async function executeWorkerTask(
|
|
task: WorkerTask,
|
|
serverUrl: string,
|
|
captureOptions: CaptureOptions,
|
|
createBeforeCaptureHook: () => BeforeCaptureHook | null,
|
|
signal?: AbortSignal,
|
|
onFrameCaptured?: (workerId: number, frameIndex: number) => void,
|
|
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
|
config?: Partial<EngineConfig>,
|
|
): Promise<WorkerResult> {
|
|
const startTime = Date.now();
|
|
let framesCaptured = 0;
|
|
|
|
if (!existsSync(task.outputDir)) mkdirSync(task.outputDir, { recursive: true });
|
|
|
|
let session: CaptureSession | null = null;
|
|
let perf: CapturePerfSummary | undefined;
|
|
|
|
try {
|
|
session = await createCaptureSession(
|
|
serverUrl,
|
|
task.outputDir,
|
|
captureOptions,
|
|
createBeforeCaptureHook(),
|
|
config,
|
|
);
|
|
await initializeSession(session);
|
|
|
|
for (let i = task.startFrame; i < task.endFrame; i++) {
|
|
if (signal?.aborted) {
|
|
throw new Error("Parallel worker cancelled");
|
|
}
|
|
// captureOptions.fps is an Fps rational; collapse to decimal for the
|
|
// frame-index → time math. The 1-in-1001 ULP loss for NTSC is invisible
|
|
// at our scales (frame count tops out at single-digit thousands).
|
|
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
|
|
|
|
if (onFrameBuffer) {
|
|
// Streaming mode: capture to buffer and invoke callback
|
|
const { buffer } = await captureFrameToBuffer(session, i, time);
|
|
await onFrameBuffer(i, buffer);
|
|
} else {
|
|
// Disk mode: capture to file
|
|
await captureFrame(session, i, time);
|
|
}
|
|
framesCaptured++;
|
|
|
|
if (onFrameCaptured) onFrameCaptured(task.workerId, i);
|
|
}
|
|
|
|
perf = getCapturePerfSummary(session);
|
|
return {
|
|
workerId: task.workerId,
|
|
framesCaptured,
|
|
startFrame: task.startFrame,
|
|
endFrame: task.endFrame,
|
|
durationMs: Date.now() - startTime,
|
|
perf,
|
|
};
|
|
} catch (error) {
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
workerId: task.workerId,
|
|
framesCaptured,
|
|
startFrame: task.startFrame,
|
|
endFrame: task.endFrame,
|
|
durationMs: Date.now() - startTime,
|
|
perf,
|
|
error: errMsg,
|
|
};
|
|
} finally {
|
|
if (session) await closeCaptureSession(session).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export async function executeParallelCapture(
|
|
serverUrl: string,
|
|
workDir: string,
|
|
tasks: WorkerTask[],
|
|
captureOptions: CaptureOptions,
|
|
createBeforeCaptureHook: () => BeforeCaptureHook | null,
|
|
signal?: AbortSignal,
|
|
onProgress?: (progress: ParallelProgress) => void,
|
|
onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
|
|
config?: Partial<EngineConfig>,
|
|
): Promise<WorkerResult[]> {
|
|
const totalFrames = tasks.reduce((sum, t) => sum + (t.endFrame - t.startFrame), 0);
|
|
const workerProgress = new Map<number, number>();
|
|
|
|
for (const task of tasks) workerProgress.set(task.workerId, 0);
|
|
|
|
const onFrameCaptured = (workerId: number, _frameIndex: number) => {
|
|
const current = workerProgress.get(workerId) || 0;
|
|
workerProgress.set(workerId, current + 1);
|
|
|
|
if (onProgress) {
|
|
const capturedFrames = Array.from(workerProgress.values()).reduce((a, b) => a + b, 0);
|
|
onProgress({
|
|
totalFrames,
|
|
capturedFrames,
|
|
activeWorkers: tasks.length,
|
|
workerProgress: new Map(workerProgress),
|
|
});
|
|
}
|
|
};
|
|
|
|
const results = await Promise.all(
|
|
tasks.map((task) =>
|
|
executeWorkerTask(
|
|
task,
|
|
serverUrl,
|
|
captureOptions,
|
|
createBeforeCaptureHook,
|
|
signal,
|
|
onFrameCaptured,
|
|
onFrameBuffer,
|
|
config,
|
|
),
|
|
),
|
|
);
|
|
|
|
const errors = results.filter((r) => r.error);
|
|
if (errors.length > 0) {
|
|
const errorMessages = errors.map((e) => `Worker ${e.workerId}: ${e.error}`).join("; ");
|
|
throw new Error(`[Parallel] Capture failed: ${errorMessages}`);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
export async function mergeWorkerFrames(
|
|
workDir: string,
|
|
tasks: WorkerTask[],
|
|
outputDir: string,
|
|
): Promise<number> {
|
|
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
|
|
let totalFrames = 0;
|
|
const sortedTasks = [...tasks].sort((a, b) => a.startFrame - b.startFrame);
|
|
|
|
for (const task of sortedTasks) {
|
|
if (!existsSync(task.outputDir)) {
|
|
continue;
|
|
}
|
|
|
|
const files = readdirSync(task.outputDir)
|
|
.filter((f) => f.startsWith("frame_") && (f.endsWith(".jpg") || f.endsWith(".png")))
|
|
.sort();
|
|
const copyTasks = files.map(async (file) => {
|
|
const sourcePath = join(task.outputDir, file);
|
|
const targetPath = join(outputDir, file);
|
|
try {
|
|
await rename(sourcePath, targetPath);
|
|
} catch {
|
|
await copyFile(sourcePath, targetPath);
|
|
}
|
|
});
|
|
await Promise.all(copyTasks);
|
|
totalFrames += files.length;
|
|
}
|
|
|
|
return totalFrames;
|
|
}
|
|
|
|
export function getSystemResources(): {
|
|
cpuCores: number;
|
|
totalMemoryMB: number;
|
|
freeMemoryMB: number;
|
|
recommendedWorkers: number;
|
|
} {
|
|
return {
|
|
cpuCores: cpus().length,
|
|
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
|
freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
|
|
recommendedWorkers: calculateOptimalWorkers(1000),
|
|
};
|
|
}
|