mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
Two review findings on the floor/telemetry PR: 1. powerStateFields() is spread into the properties object at the CALL SITE, so it ran before trackEvent's own `if (!shouldTrack()) return` guard — telemetry-disabled installs paid two blocking `pmset` subprocess spawns per render for an event that was then discarded. Now short-circuits on shouldTrack() (memoized, so no cost on the tracked path). Regression test asserts pmset is not sampled when telemetry is off; fault-injection verified it fails without the guard. 2. The DE parallel router pinned workerCount to 3 and skipped calibration even when verified parallel DE STREAMING — the entire reason for the pin — could not run for that render. The common case is a composition over streamingEncodeMaxDurationSeconds (240 s default): the duration cap disables streaming before the router's force flag is consulted, so the render got a hard-coded 3 workers chosen by a benchmark for a path it was not on, instead of the calibrated count. shouldPreferParallelDrawElement now takes parallelStreamingAvailable and withholds the bet without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2144 lines
72 KiB
TypeScript
2144 lines
72 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||
import { join, win32 } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
import type { CaptureOptions, EngineConfig, ExtractedFrames } from "@hyperframes/engine";
|
||
import { executeParallelCapture, mergeWorkerFrames } from "@hyperframes/engine";
|
||
import type { CompiledComposition } from "./htmlCompiler.js";
|
||
|
||
// Replace only the two engine functions the adaptive-retry loop uses to touch
|
||
// disk; everything else (distributeFrames, types, etc.) stays real so the loop
|
||
// runs for real against a temp framesDir.
|
||
vi.mock("@hyperframes/engine", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("@hyperframes/engine")>();
|
||
return { ...actual, executeParallelCapture: vi.fn(), mergeWorkerFrames: vi.fn() };
|
||
});
|
||
|
||
import {
|
||
buildMissingFrameRetryBatches,
|
||
captureAttemptMadeProgress,
|
||
describeMemoryExhaustion,
|
||
executeDiskCaptureWithAdaptiveRetry,
|
||
collectVideoMetadataHints,
|
||
collectVideoReadinessSkipIds,
|
||
extractStandaloneEntryFromIndex,
|
||
findMissingFrameRanges,
|
||
getNextRetryWorkerCount,
|
||
isRecoverableParallelCaptureError,
|
||
MAX_TRANSIENT_CAPTURE_RETRIES,
|
||
resolveCaptureForceScreenshotForPageSideCompositing,
|
||
resolveRenderWorkDirPrefix,
|
||
shouldDiscardProbeSessionForPageSideCompositing,
|
||
resolveInversionRetryPlan,
|
||
resolveParallelRouterRetryPlan,
|
||
resetCaptureAttemptProgress,
|
||
shouldRetryViaPinnedFallback,
|
||
shouldPreferParallelDrawElement,
|
||
shouldPreferSingleWorkerDrawElement,
|
||
shouldStreamParallelCapture,
|
||
shouldUseStreamingEncode,
|
||
} from "./renderOrchestrator.js";
|
||
import { ensureFrameWritten } from "./render/stages/captureHdrFrameShared.js";
|
||
import { resolveCompositeTransfer, shouldUseLayeredComposite } from "./hdrCompositor.js";
|
||
import {
|
||
createCaptureCalibrationConfig,
|
||
estimateCaptureCostMultiplier,
|
||
estimateMeasuredCaptureCostMultiplier,
|
||
resolveRenderWorkerCount,
|
||
selectCaptureCalibrationFrames,
|
||
shouldFallbackToScreenshotAfterCalibrationError,
|
||
} from "./render/captureCost.js";
|
||
import {
|
||
applyRenderModeHints,
|
||
createCompiledFrameSrcResolver,
|
||
materializeExtractedFramesForCompiledDir,
|
||
projectBrowserEndToCompositionTimeline,
|
||
resolveDeviceScaleFactor,
|
||
writeCompiledArtifacts,
|
||
} from "./render/shared.js";
|
||
import { formatCaptureFrameName, toExternalAssetKey } from "../utils/paths.js";
|
||
|
||
describe("resolveRenderWorkDirPrefix", () => {
|
||
it("uses a short system temp prefix on Windows instead of the output path", () => {
|
||
const outputPath = win32.join("C:\\deep", "nested".repeat(30), "renders", "final.mp4");
|
||
|
||
expect(resolveRenderWorkDirPrefix(outputPath, "long-render-job-id", "win32", "C:/Temp")).toBe(
|
||
join("C:/Temp", "hf-render-"),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("extractStandaloneEntryFromIndex", () => {
|
||
it("reuses the index wrapper and keeps only the requested composition host", () => {
|
||
const indexHtml = `<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<style>body { background: #111; }</style>
|
||
</head>
|
||
<body>
|
||
<div id="main" data-composition-id="root" data-width="1920" data-height="1080">
|
||
<div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="5"></div>
|
||
<div id="outro" data-composition-id="outro" data-composition-src="compositions/outro.html" data-start="12"></div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/outro.html");
|
||
|
||
expect(extracted).toContain('data-composition-id="root"');
|
||
expect(extracted).toContain('id="outro"');
|
||
expect(extracted).toContain('data-composition-src="compositions/outro.html"');
|
||
expect(extracted).toContain('data-start="0"');
|
||
expect(extracted).not.toContain('id="intro"');
|
||
expect(extracted).toContain("<style>body { background: #111; }</style>");
|
||
});
|
||
|
||
it("matches normalized data-composition-src paths", () => {
|
||
const indexHtml = `<!DOCTYPE html>
|
||
<html>
|
||
<body>
|
||
<div data-composition-id="root" data-width="1920" data-height="1080">
|
||
<div id="intro" data-composition-id="intro" data-composition-src="./compositions/intro.html" data-start="3"></div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/intro.html");
|
||
|
||
expect(extracted).not.toBeNull();
|
||
expect(extracted).toContain('data-start="0"');
|
||
expect(extracted).toContain('data-composition-src="./compositions/intro.html"');
|
||
});
|
||
|
||
it("returns null when index.html does not mount the requested entry file", () => {
|
||
const indexHtml = `<!DOCTYPE html>
|
||
<html>
|
||
<body>
|
||
<div data-composition-id="root" data-width="1920" data-height="1080">
|
||
<div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html"></div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/outro.html");
|
||
|
||
expect(extracted).toBeNull();
|
||
});
|
||
|
||
it("re-points the wrapper duration at the scene's own, not the master's", () => {
|
||
const indexHtml = `<!DOCTYPE html>
|
||
<html>
|
||
<body>
|
||
<div data-composition-id="master" data-width="640" data-height="360" data-duration="12">
|
||
<div id="scene1" data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start="0" data-duration="2"></div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
const sceneHtml = `<template id="scene1-template"><div data-composition-id="scene1" data-width="640" data-height="360" data-duration="3"></div></template>`;
|
||
|
||
const extracted = extractStandaloneEntryFromIndex(
|
||
indexHtml,
|
||
"compositions/scene1.html",
|
||
sceneHtml,
|
||
);
|
||
|
||
// The extracted standalone advertises the scene file's 3s, not the mount's 2s or master's 12s.
|
||
expect(extracted).toContain('data-duration="3"');
|
||
expect(extracted).not.toContain('data-duration="12"');
|
||
});
|
||
|
||
it("falls back to the mount's data-duration when the scene file isn't supplied", () => {
|
||
const indexHtml = `<!DOCTYPE html>
|
||
<html>
|
||
<body>
|
||
<div data-composition-id="master" data-width="640" data-height="360" data-duration="12">
|
||
<div id="scene1" data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start="0" data-duration="2"></div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const extracted = extractStandaloneEntryFromIndex(indexHtml, "compositions/scene1.html");
|
||
|
||
expect(extracted).toContain('data-duration="2"');
|
||
expect(extracted).not.toContain('data-duration="12"');
|
||
});
|
||
});
|
||
|
||
describe("captureAttemptMadeProgress", () => {
|
||
it("resets completed frames before a fallback attempt starts", () => {
|
||
const job = { framesRendered: 900 };
|
||
|
||
resetCaptureAttemptProgress(job);
|
||
|
||
expect(job.framesRendered).toBe(0);
|
||
});
|
||
|
||
it("retries when the attempt captured at least one frame toward its target", () => {
|
||
// targeted 100 frames, 40 still missing -> 60 captured -> worth retrying the rest
|
||
expect(captureAttemptMadeProgress(100, 40)).toBe(true);
|
||
expect(captureAttemptMadeProgress(100, 99)).toBe(true);
|
||
});
|
||
|
||
it("bails when the attempt captured nothing (structurally broken composition)", () => {
|
||
// targeted 100 frames, 100 still missing -> zero progress -> don't burn another timeout cycle
|
||
expect(captureAttemptMadeProgress(100, 100)).toBe(false);
|
||
// defensive: never-greater-than guard, treat >= target as no progress
|
||
expect(captureAttemptMadeProgress(100, 120)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("executeDiskCaptureWithAdaptiveRetry — zero-progress bail (integration)", () => {
|
||
const makeLog = () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() });
|
||
|
||
afterEach(() => {
|
||
vi.mocked(executeParallelCapture).mockReset();
|
||
vi.mocked(mergeWorkerFrames).mockReset();
|
||
});
|
||
|
||
it("runs exactly one attempt (no worker-halving retries) when an attempt captures zero frames", async () => {
|
||
// Capture writes nothing -> framesDir stays empty -> every frame still missing.
|
||
vi.mocked(executeParallelCapture).mockResolvedValue([]);
|
||
vi.mocked(mergeWorkerFrames).mockResolvedValue(undefined);
|
||
|
||
const workDir = mkdtempSync(join(tmpdir(), "hf-retry-work-"));
|
||
const framesDir = mkdtempSync(join(tmpdir(), "hf-retry-frames-"));
|
||
const log = makeLog();
|
||
try {
|
||
await expect(
|
||
executeDiskCaptureWithAdaptiveRetry({
|
||
serverUrl: "http://localhost:0",
|
||
workDir,
|
||
framesDir,
|
||
totalFrames: 4,
|
||
initialWorkerCount: 4,
|
||
allowRetry: true,
|
||
frameExt: "jpg",
|
||
captureOptions: {} as CaptureOptions,
|
||
createBeforeCaptureHook: () => null,
|
||
cfg: {} as EngineConfig,
|
||
log,
|
||
dedupPerfs: [],
|
||
}),
|
||
).rejects.toThrow(/4 frame\(s\) are missing/);
|
||
|
||
// The gate under test: without it the loop would walk 4 -> 2 -> 1 workers
|
||
// (3 capture calls) before giving up. One call proves it bailed immediately.
|
||
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(1);
|
||
expect(log.warn).toHaveBeenCalledWith(
|
||
expect.stringContaining("no forward progress"),
|
||
expect.objectContaining({ attempt: 0, frameCount: 4, remainingCount: 4 }),
|
||
);
|
||
} finally {
|
||
rmSync(workDir, { recursive: true, force: true });
|
||
rmSync(framesDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("executeDiskCaptureWithAdaptiveRetry — transient Target-closed single retry (integration)", () => {
|
||
const makeLog = () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() });
|
||
|
||
const writeAllFrames = (framesDir: string, totalFrames: number): void => {
|
||
for (let i = 0; i < totalFrames; i++) {
|
||
writeFileSync(join(framesDir, formatCaptureFrameName(i, "jpg")), "captured-frame");
|
||
}
|
||
};
|
||
|
||
afterEach(() => {
|
||
vi.mocked(executeParallelCapture).mockReset();
|
||
vi.mocked(mergeWorkerFrames).mockReset();
|
||
});
|
||
|
||
it("retries ONCE at the same worker count on a transient Target closed with zero progress", async () => {
|
||
const workDir = mkdtempSync(join(tmpdir(), "hf-transient-work-"));
|
||
const framesDir = mkdtempSync(join(tmpdir(), "hf-transient-frames-"));
|
||
const log = makeLog();
|
||
let call = 0;
|
||
// First attempt: the tab dies before any frame is captured (frame 0) — zero
|
||
// forward progress, which the worker-halving retry deliberately bails on.
|
||
// The transient retry recovers it without changing the worker count.
|
||
vi.mocked(executeParallelCapture).mockImplementation(async () => {
|
||
call++;
|
||
if (call === 1) {
|
||
throw new Error("Protocol error (Page.captureScreenshot): Target closed");
|
||
}
|
||
writeAllFrames(framesDir, 4);
|
||
return [];
|
||
});
|
||
vi.mocked(mergeWorkerFrames).mockResolvedValue(undefined);
|
||
|
||
try {
|
||
const attempts = await executeDiskCaptureWithAdaptiveRetry({
|
||
serverUrl: "http://localhost:0",
|
||
workDir,
|
||
framesDir,
|
||
totalFrames: 4,
|
||
initialWorkerCount: 1,
|
||
allowRetry: true,
|
||
frameExt: "jpg",
|
||
captureOptions: {} as CaptureOptions,
|
||
createBeforeCaptureHook: () => null,
|
||
cfg: {} as EngineConfig,
|
||
log,
|
||
dedupPerfs: [],
|
||
});
|
||
|
||
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(2);
|
||
// Both attempts ran at the same worker count (transient retry doesn't halve).
|
||
expect(attempts.map((a) => a.workers)).toEqual([1, 1]);
|
||
// The retry attempt is tagged `transient-retry` (vs the worker-halving
|
||
// `retry`) so it's countable for telemetry (dashboard 1783183).
|
||
expect(attempts.map((a) => a.reason)).toEqual(["initial", "transient-retry"]);
|
||
expect(log.warn).toHaveBeenCalledWith(
|
||
expect.stringContaining("Transient browser failure"),
|
||
expect.objectContaining({ transientRetriesUsed: 1 }),
|
||
);
|
||
} finally {
|
||
rmSync(workDir, { recursive: true, force: true });
|
||
rmSync(framesDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("does NOT retry a transient error when the render was aborted", async () => {
|
||
const workDir = mkdtempSync(join(tmpdir(), "hf-transient-abort-work-"));
|
||
const framesDir = mkdtempSync(join(tmpdir(), "hf-transient-abort-frames-"));
|
||
const log = makeLog();
|
||
const controller = new AbortController();
|
||
// Cancellation tears the browser down, surfacing as a transient-looking
|
||
// "Target closed" — but an aborted render must fail immediately, not retry.
|
||
vi.mocked(executeParallelCapture).mockImplementation(async () => {
|
||
controller.abort();
|
||
throw new Error("Target closed");
|
||
});
|
||
vi.mocked(mergeWorkerFrames).mockResolvedValue(undefined);
|
||
|
||
try {
|
||
await expect(
|
||
executeDiskCaptureWithAdaptiveRetry({
|
||
serverUrl: "http://localhost:0",
|
||
workDir,
|
||
framesDir,
|
||
totalFrames: 4,
|
||
initialWorkerCount: 2,
|
||
allowRetry: true,
|
||
frameExt: "jpg",
|
||
captureOptions: {} as CaptureOptions,
|
||
createBeforeCaptureHook: () => null,
|
||
abortSignal: controller.signal,
|
||
cfg: {} as EngineConfig,
|
||
log,
|
||
dedupPerfs: [],
|
||
}),
|
||
).rejects.toThrow(/Target closed/);
|
||
|
||
// Exactly one attempt — no transient retry burned on a cancelled render.
|
||
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(1);
|
||
expect(log.warn).not.toHaveBeenCalledWith(
|
||
expect.stringContaining("Transient browser failure"),
|
||
expect.anything(),
|
||
);
|
||
} finally {
|
||
rmSync(workDir, { recursive: true, force: true });
|
||
rmSync(framesDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("gives up after MAX_TRANSIENT_CAPTURE_RETRIES when the tab keeps dying", async () => {
|
||
const workDir = mkdtempSync(join(tmpdir(), "hf-transient2-work-"));
|
||
const framesDir = mkdtempSync(join(tmpdir(), "hf-transient2-frames-"));
|
||
const log = makeLog();
|
||
vi.mocked(executeParallelCapture).mockRejectedValue(new Error("Session closed"));
|
||
vi.mocked(mergeWorkerFrames).mockResolvedValue(undefined);
|
||
|
||
try {
|
||
await expect(
|
||
executeDiskCaptureWithAdaptiveRetry({
|
||
serverUrl: "http://localhost:0",
|
||
workDir,
|
||
framesDir,
|
||
totalFrames: 4,
|
||
initialWorkerCount: 1,
|
||
allowRetry: true,
|
||
frameExt: "jpg",
|
||
captureOptions: {} as CaptureOptions,
|
||
createBeforeCaptureHook: () => null,
|
||
cfg: {} as EngineConfig,
|
||
log,
|
||
dedupPerfs: [],
|
||
}),
|
||
).rejects.toThrow(/Session closed/);
|
||
|
||
// 1 initial attempt + exactly MAX_TRANSIENT_CAPTURE_RETRIES retries.
|
||
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(
|
||
1 + MAX_TRANSIENT_CAPTURE_RETRIES,
|
||
);
|
||
} finally {
|
||
rmSync(workDir, { recursive: true, force: true });
|
||
rmSync(framesDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("describeMemoryExhaustion", () => {
|
||
it("returns actionable guidance for a memory-exhaustion error", () => {
|
||
const msg = describeMemoryExhaustion(new Error("Set maximum size exceeded"), {
|
||
width: 3840,
|
||
height: 2160,
|
||
totalFrames: 5400,
|
||
});
|
||
expect(msg).not.toBeNull();
|
||
expect(msg).toContain("ran out of memory");
|
||
expect(msg).toContain("3840×2160");
|
||
expect(msg).toContain("5400 frames");
|
||
expect(msg).toContain("Set maximum size exceeded");
|
||
expect(msg).toContain("--low-memory-mode");
|
||
});
|
||
|
||
it("omits dimensions when they are unknown", () => {
|
||
const msg = describeMemoryExhaustion(new Error("JavaScript heap out of memory"), {});
|
||
expect(msg).not.toBeNull();
|
||
expect(msg).not.toContain("×");
|
||
});
|
||
|
||
it("returns null for a non-memory error (leaves the original message intact)", () => {
|
||
expect(
|
||
describeMemoryExhaustion(new Error("Target closed"), {
|
||
width: 1920,
|
||
height: 1080,
|
||
totalFrames: 100,
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("ensureFrameWritten", () => {
|
||
it("returns without throwing when the frame was written", () => {
|
||
expect(() => ensureFrameWritten(true, 0)).not.toThrow();
|
||
});
|
||
|
||
it("throws a bare frame-indexed error when no encoder context is supplied", () => {
|
||
expect(() => ensureFrameWritten(false, 7)).toThrow(
|
||
"Streaming encoder exited before frame 7 was written",
|
||
);
|
||
});
|
||
|
||
it("includes the ffmpeg exit reason when the encoder reports one", () => {
|
||
const encoder = { getExitError: () => "FFmpeg exited with code 1: Unknown encoder 'libx264'" };
|
||
expect(() => ensureFrameWritten(false, 0, encoder)).toThrow(
|
||
/Streaming encoder exited before frame 0 was written: FFmpeg exited with code 1: Unknown encoder 'libx264'/,
|
||
);
|
||
});
|
||
|
||
it("falls back to the bare message when the encoder has no exit reason yet", () => {
|
||
const encoder = { getExitError: () => undefined };
|
||
expect(() => ensureFrameWritten(false, 3, encoder)).toThrow(
|
||
"Streaming encoder exited before frame 3 was written",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("shouldUseStreamingEncode", () => {
|
||
const streamingEnabledConfig = {
|
||
enableStreamingEncode: true,
|
||
streamingEncodeMaxDurationSeconds: 240,
|
||
lowMemoryMode: false,
|
||
};
|
||
|
||
it("enables streaming for default single-worker video renders", () => {
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240)).toBe(true);
|
||
});
|
||
|
||
it("lets config disable streaming encode", () => {
|
||
expect(
|
||
shouldUseStreamingEncode(
|
||
{ enableStreamingEncode: false, streamingEncodeMaxDurationSeconds: 240 },
|
||
"mp4",
|
||
1,
|
||
240,
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("keeps png-sequence and parallel capture on the non-streaming path", () => {
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "png-sequence", 1, 240)).toBe(false);
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 2, 240)).toBe(false);
|
||
});
|
||
|
||
it("forceParallelStream overrides the parallel-capture clamp for verified multi-worker streaming", () => {
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 3, 240, true)).toBe(true);
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 3, 240, false)).toBe(false);
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "png-sequence", 3, 240, true)).toBe(
|
||
false,
|
||
);
|
||
});
|
||
|
||
it("keeps renders over the configured max duration on normal encoding", () => {
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240)).toBe(true);
|
||
expect(shouldUseStreamingEncode(streamingEnabledConfig, "mp4", 1, 240.001)).toBe(false);
|
||
expect(
|
||
shouldUseStreamingEncode(
|
||
{ enableStreamingEncode: true, streamingEncodeMaxDurationSeconds: 120 },
|
||
"mp4",
|
||
1,
|
||
120.001,
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("keeps long single-worker renders streaming in low-memory mode", () => {
|
||
expect(
|
||
shouldUseStreamingEncode({ ...streamingEnabledConfig, lowMemoryMode: true }, "mp4", 1, 411),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("createCompiledFrameSrcResolver", () => {
|
||
it("maps extracted frame paths under compiledDir to encoded server URLs", () => {
|
||
const resolver = createCompiledFrameSrcResolver("/tmp/hf job/compiled");
|
||
|
||
expect(
|
||
resolver("/tmp/hf job/compiled/__hyperframes_video_frames/video 1/frame_00001.jpg"),
|
||
).toBe("/__hyperframes_video_frames/video%201/frame_00001.jpg");
|
||
});
|
||
|
||
it("returns null for paths outside compiledDir", () => {
|
||
const resolver = createCompiledFrameSrcResolver("/tmp/hf-job/compiled");
|
||
|
||
expect(resolver("/tmp/hf-job/video-frames/frame_00001.jpg")).toBeNull();
|
||
});
|
||
|
||
it("resolves symlinked cache frames when materialized under compiledDir", () => {
|
||
const resolver = createCompiledFrameSrcResolver("/tmp/hf-job/compiled");
|
||
|
||
expect(resolver("/tmp/hf-job/compiled/__hyperframes_video_frames/vid1/frame_00001.jpg")).toBe(
|
||
"/__hyperframes_video_frames/vid1/frame_00001.jpg",
|
||
);
|
||
|
||
expect(resolver("/tmp/cache/abc123/frame_00001.jpg")).toBeNull();
|
||
});
|
||
|
||
it("encodes reserved characters in frame path segments", () => {
|
||
const resolver = createCompiledFrameSrcResolver("/tmp/hf-job/compiled");
|
||
|
||
expect(
|
||
resolver("/tmp/hf-job/compiled/__hyperframes_video_frames/video#1/frame_00001.jpg"),
|
||
).toBe("/__hyperframes_video_frames/video%231/frame_00001.jpg");
|
||
|
||
expect(
|
||
resolver("/tmp/hf-job/compiled/__hyperframes_video_frames/video?q=1/frame_00001.jpg"),
|
||
).toBe("/__hyperframes_video_frames/video%3Fq%3D1/frame_00001.jpg");
|
||
});
|
||
});
|
||
|
||
describe("materializeExtractedFramesForCompiledDir", () => {
|
||
function createExtractedFrames(
|
||
outputDir: string,
|
||
framePath: string,
|
||
): Pick<ExtractedFrames, "videoId" | "outputDir" | "framePaths"> {
|
||
return {
|
||
videoId: "video-1",
|
||
outputDir,
|
||
framePaths: new Map([[0, framePath]]),
|
||
};
|
||
}
|
||
|
||
it("leaves Windows frame paths already under compiledDir unchanged", () => {
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => {
|
||
throw new Error("inside compiledDir should not touch the filesystem");
|
||
},
|
||
mkdirSync: () => {
|
||
throw new Error("inside compiledDir should not mkdir");
|
||
},
|
||
symlinkSync: () => {
|
||
throw new Error("inside compiledDir should not symlink");
|
||
},
|
||
cpSync: () => {
|
||
throw new Error("inside compiledDir should not copy");
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(extracted.outputDir).toBe(outputDir);
|
||
expect(extracted.framePaths.get(0)).toBe(framePath);
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("remaps Windows cache frames under compiledDir using only the frame basename", () => {
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
const symlinks: Array<{ target: string; path: string }> = [];
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: (target, path) => {
|
||
symlinks.push({ target, path });
|
||
},
|
||
cpSync: () => {
|
||
throw new Error("symlink path should not invoke cpSync");
|
||
},
|
||
},
|
||
});
|
||
|
||
const linkPath = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
expect(extracted.outputDir).toBe(linkPath);
|
||
expect(extracted.framePaths.get(0)).toBe(win32.join(linkPath, "frame_000001.jpg"));
|
||
expect(extracted.framePaths.get(0)).not.toContain(outputDir);
|
||
expect(symlinks).toEqual([{ target: outputDir, path: linkPath }]);
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("recursively copies frames into compiledDir when materializeSymlinks is true", () => {
|
||
// Distributed plan() must produce a self-contained planDir — symlinks
|
||
// don't survive S3 / GCS round-trips. With materializeSymlinks=true the
|
||
// helper invokes cpSync(recursive) instead of symlinkSync.
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
const copies: Array<{ src: string; dest: string; recursive: boolean }> = [];
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: () => {
|
||
throw new Error("copy path should not invoke symlinkSync");
|
||
},
|
||
cpSync: (src, dest, options) => {
|
||
copies.push({ src, dest, recursive: options.recursive });
|
||
},
|
||
},
|
||
materializeSymlinks: true,
|
||
});
|
||
|
||
const linkPath = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
expect(extracted.outputDir).toBe(linkPath);
|
||
expect(extracted.framePaths.get(0)).toBe(win32.join(linkPath, "frame_000001.jpg"));
|
||
expect(copies).toEqual([{ src: outputDir, dest: linkPath, recursive: true }]);
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("falls back to copying frames when symlinkSync fails with EPERM (Windows, no Developer Mode)", () => {
|
||
// Windows without Developer Mode/Administrator rejects symlink creation with
|
||
// EPERM — high/standard-quality renders failed here while draft worked. The
|
||
// helper must degrade to a recursive copy instead of throwing.
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
const copies: Array<{ src: string; dest: string; recursive: boolean }> = [];
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: () => {
|
||
const err: NodeJS.ErrnoException = new Error("EPERM: operation not permitted, symlink");
|
||
err.code = "EPERM";
|
||
throw err;
|
||
},
|
||
cpSync: (src, dest, options) => {
|
||
copies.push({ src, dest, recursive: options.recursive });
|
||
},
|
||
},
|
||
});
|
||
|
||
const linkPath = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
expect(copies).toEqual([{ src: outputDir, dest: linkPath, recursive: true }]);
|
||
expect(extracted.outputDir).toBe(linkPath);
|
||
expect(extracted.framePaths.get(0)).toBe(win32.join(linkPath, "frame_000001.jpg"));
|
||
});
|
||
|
||
it("rethrows a non-permission symlink error instead of masking it with a copy", () => {
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
|
||
expect(() =>
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: () => {
|
||
const err: NodeJS.ErrnoException = new Error("ENOSPC: no space left");
|
||
err.code = "ENOSPC";
|
||
throw err;
|
||
},
|
||
cpSync: () => {
|
||
throw new Error("must not fall back to copy for a non-permission error");
|
||
},
|
||
},
|
||
}),
|
||
).toThrow(/ENOSPC/);
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("clears a stale dangling entry and re-stages when symlinkSync fails with EEXIST", () => {
|
||
// After the extraction cache is GC'd, a symlink from a prior render dangles
|
||
// (its target removed). existsSync() follows the dead link so the caller's
|
||
// guard reads it as absent and reaches staging, but the link file itself
|
||
// still exists, so symlinkSync collides with EEXIST. The helper must clear
|
||
// the stale entry (rmSync) and re-stage, not hard-fail the render.
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
const linkPath = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
const removed: string[] = [];
|
||
const symlinks: Array<{ target: string; path: string }> = [];
|
||
let symlinkCalls = 0;
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: (target, path) => {
|
||
symlinkCalls += 1;
|
||
if (symlinkCalls === 1) {
|
||
const err: NodeJS.ErrnoException = new Error("EEXIST: file already exists, symlink");
|
||
err.code = "EEXIST";
|
||
throw err;
|
||
}
|
||
symlinks.push({ target, path });
|
||
},
|
||
cpSync: () => {
|
||
throw new Error("EEXIST recovery should re-link, not copy");
|
||
},
|
||
rmSync: (path) => {
|
||
removed.push(path);
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(removed).toEqual([linkPath]);
|
||
expect(symlinks).toEqual([{ target: outputDir, path: linkPath }]);
|
||
expect(extracted.framePaths.get(0)).toBe(win32.join(linkPath, "frame_000001.jpg"));
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("falls back to copying when symlinkSync fails with UNKNOWN (some Windows privilege denials)", () => {
|
||
// Some Windows builds surface a no-symlink-privilege denial as an
|
||
// UNKNOWN-coded error rather than EPERM/EACCES — it must still degrade to a
|
||
// copy, not hard-fail the render.
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
const copies: Array<{ src: string; dest: string; recursive: boolean }> = [];
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: () => {
|
||
const err: NodeJS.ErrnoException = new Error("UNKNOWN: unknown error, symlink");
|
||
err.code = "UNKNOWN";
|
||
throw err;
|
||
},
|
||
cpSync: (src, dest, options) => {
|
||
copies.push({ src, dest, recursive: options.recursive });
|
||
},
|
||
},
|
||
});
|
||
|
||
const linkPath = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
expect(copies).toEqual([{ src: outputDir, dest: linkPath, recursive: true }]);
|
||
expect(extracted.framePaths.get(0)).toBe(win32.join(linkPath, "frame_000001.jpg"));
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("clears a stale entry and re-copies when the eager-copy path (materializeSymlinks) hits EEXIST", () => {
|
||
// #2025 routes Windows through the eager-copy branch. Reusing a dir a prior
|
||
// Linux run populated with a (now dangling) symlink makes cpSync collide
|
||
// with EEXIST — the recovery must clear the stale entry and re-copy, exactly
|
||
// like the symlink path does.
|
||
const compiledDir = win32.resolve("C:\\compiled");
|
||
const outputDir = win32.resolve("D:\\cache\\abc123");
|
||
const framePath = win32.join(outputDir, "frame_000001.jpg");
|
||
const extracted = createExtractedFrames(outputDir, framePath);
|
||
const linkPath = win32.join(compiledDir, "__hyperframes_video_frames", "video-1");
|
||
const removed: string[] = [];
|
||
const copies: Array<{ src: string; dest: string }> = [];
|
||
let cpCalls = 0;
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
|
||
pathModule: win32,
|
||
materializeSymlinks: true,
|
||
fileSystem: {
|
||
existsSync: () => false,
|
||
mkdirSync: () => undefined,
|
||
symlinkSync: () => {
|
||
throw new Error("eager-copy path must not symlink");
|
||
},
|
||
cpSync: (src, dest) => {
|
||
cpCalls += 1;
|
||
if (cpCalls === 1) {
|
||
const err: NodeJS.ErrnoException = new Error("EEXIST: file already exists, cp");
|
||
err.code = "EEXIST";
|
||
throw err;
|
||
}
|
||
copies.push({ src, dest });
|
||
},
|
||
rmSync: (path) => {
|
||
removed.push(path);
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(removed).toEqual([linkPath]);
|
||
expect(copies).toEqual([{ src: outputDir, dest: linkPath }]);
|
||
expect(extracted.framePaths.get(0)).toBe(win32.join(linkPath, "frame_000001.jpg"));
|
||
});
|
||
});
|
||
|
||
describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => {
|
||
const tempDirs: string[] = [];
|
||
afterEach(() => {
|
||
while (tempDirs.length > 0) {
|
||
const d = tempDirs.pop();
|
||
if (d) {
|
||
try {
|
||
rmSync(d, { recursive: true, force: true });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
function makeWorkDir(): string {
|
||
const d = mkdtempSync(join(tmpdir(), "hf-orch-"));
|
||
tempDirs.push(d);
|
||
return d;
|
||
}
|
||
|
||
it("copies an external asset with a Windows-style drive-letter key into compileDir", () => {
|
||
const workDir = makeWorkDir();
|
||
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
|
||
tempDirs.push(sourceDir);
|
||
const srcFile = join(sourceDir, "segment.wav");
|
||
writeFileSync(srcFile, "fake wav bytes");
|
||
|
||
const windowsStyleInput = "D:\\coder\\assets\\segment.wav";
|
||
const key = toExternalAssetKey(windowsStyleInput);
|
||
expect(key).toBe("hf-ext/D/coder/assets/segment.wav");
|
||
|
||
const externalAssets = new Map<string, string>([[key, srcFile]]);
|
||
const compiled = {
|
||
html: "<!doctype html><html><body></body></html>",
|
||
subCompositions: new Map<string, string>(),
|
||
videos: [],
|
||
audios: [],
|
||
unresolvedCompositions: [],
|
||
externalAssets,
|
||
width: 1920,
|
||
height: 1080,
|
||
staticDuration: 10,
|
||
renderModeHints: {
|
||
recommendScreenshot: false,
|
||
reasons: [],
|
||
},
|
||
hasShaderTransitions: false,
|
||
};
|
||
|
||
writeCompiledArtifacts(compiled, workDir, false);
|
||
|
||
const landed = join(workDir, "compiled", key);
|
||
expect(existsSync(landed)).toBe(true);
|
||
expect(readFileSync(landed, "utf-8")).toBe("fake wav bytes");
|
||
});
|
||
|
||
it("rejects a maliciously crafted key that tries to escape compileDir", () => {
|
||
const sandboxRoot = mkdtempSync(join(tmpdir(), "hf-orch-root-"));
|
||
tempDirs.push(sandboxRoot);
|
||
const workDir = join(sandboxRoot, "work", "inner");
|
||
mkdirSync(workDir, { recursive: true });
|
||
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
|
||
tempDirs.push(sourceDir);
|
||
const srcFile = join(sourceDir, "evil.wav");
|
||
writeFileSync(srcFile, "should never be copied");
|
||
|
||
const externalAssets = new Map<string, string>([["hf-ext/../../etc/passwd", srcFile]]);
|
||
const compiled = {
|
||
html: "<!doctype html>",
|
||
subCompositions: new Map<string, string>(),
|
||
videos: [],
|
||
audios: [],
|
||
unresolvedCompositions: [],
|
||
externalAssets,
|
||
width: 1920,
|
||
height: 1080,
|
||
staticDuration: 10,
|
||
renderModeHints: {
|
||
recommendScreenshot: false,
|
||
reasons: [],
|
||
},
|
||
hasShaderTransitions: false,
|
||
};
|
||
|
||
writeCompiledArtifacts(compiled, workDir, false);
|
||
|
||
const escapeTarget = join(workDir, "etc", "passwd");
|
||
expect(existsSync(escapeTarget)).toBe(false);
|
||
});
|
||
});
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
function createConfig(): EngineConfig {
|
||
return {
|
||
fps: 30,
|
||
quality: "standard",
|
||
format: "jpeg",
|
||
jpegQuality: 80,
|
||
concurrency: "auto",
|
||
coresPerWorker: 2.5,
|
||
minParallelFrames: 120,
|
||
largeRenderThreshold: 1000,
|
||
disableGpu: false,
|
||
browserGpuMode: "software",
|
||
enableBrowserPool: false,
|
||
browserTimeout: 120000,
|
||
protocolTimeout: 300000,
|
||
forceScreenshot: false,
|
||
lowMemoryMode: false,
|
||
enableChunkedEncode: false,
|
||
chunkSizeFrames: 360,
|
||
enableStreamingEncode: false,
|
||
streamingEncodeMaxDurationSeconds: 240,
|
||
ffmpegEncodeTimeout: 600000,
|
||
ffmpegProcessTimeout: 300000,
|
||
ffmpegStreamingTimeout: 600000,
|
||
hdr: false,
|
||
hdrAutoDetect: true,
|
||
audioGain: 1,
|
||
frameDataUriCacheLimit: 256,
|
||
frameDataUriCacheBytesLimitMb: 1500,
|
||
playerReadyTimeout: 45000,
|
||
renderReadyTimeout: 15000,
|
||
verifyRuntime: true,
|
||
debug: false,
|
||
};
|
||
}
|
||
|
||
describe("applyRenderModeHints", () => {
|
||
// fallow-ignore-next-line code-duplication
|
||
it("forces screenshot mode when compatibility hints recommend it", () => {
|
||
const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]);
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const result = applyRenderModeHints(false, compiled, log);
|
||
|
||
expect(result).toEqual({ forceScreenshot: true, autoSelected: true });
|
||
expect(log.warn).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("does nothing when screenshot mode is already forced", () => {
|
||
const compiled = createCompiledComposition(["iframe"]);
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const result = applyRenderModeHints(true, compiled, log);
|
||
|
||
expect(result).toEqual({ forceScreenshot: true, autoSelected: false });
|
||
expect(log.warn).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("returns false when neither caller nor hint forces", () => {
|
||
const compiled = createCompiledComposition([]);
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const result = applyRenderModeHints(false, compiled, log);
|
||
|
||
expect(result).toEqual({ forceScreenshot: false, autoSelected: false });
|
||
expect(log.warn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("collectVideoReadinessSkipIds", () => {
|
||
it("skips native metadata waits for every injected video with dimensions", () => {
|
||
expect(
|
||
collectVideoReadinessSkipIds(new Set(["hdr-video"]), [
|
||
{ videoId: "video1", metadata: { width: 1920, height: 1080 } },
|
||
{ videoId: "video2", metadata: { width: 1920, height: 1080 } },
|
||
{ videoId: "video3", metadata: { width: 1920, height: 1080 } },
|
||
{ videoId: "hdr-video", metadata: { width: 1920, height: 1080 } },
|
||
{ videoId: "bad-metadata", metadata: { width: 0, height: 0 } },
|
||
]),
|
||
).toEqual(["hdr-video", "video1", "video2", "video3"]);
|
||
});
|
||
});
|
||
|
||
describe("collectVideoMetadataHints", () => {
|
||
it("passes extracted video dimensions to capture sessions", () => {
|
||
expect(
|
||
collectVideoMetadataHints([
|
||
{ videoId: "video2", metadata: { width: 1080, height: 1920, durationSeconds: 4 } },
|
||
{ videoId: "video1", metadata: { width: 1920, height: 1080, durationSeconds: 12 } },
|
||
{ videoId: "bad-metadata", metadata: { width: 0, height: 1080, durationSeconds: 1 } },
|
||
]),
|
||
).toEqual([
|
||
{ id: "video1", width: 1920, height: 1080 },
|
||
{ id: "video2", width: 1080, height: 1920 },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("resolveRenderWorkerCount", () => {
|
||
const cfg = { ...createConfig(), coresPerWorker: 100 };
|
||
|
||
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: [] },
|
||
},
|
||
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: [] },
|
||
},
|
||
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: [] },
|
||
},
|
||
undefined,
|
||
{ multiplier: 4, reasons: ["calibration-p95=2400ms"] },
|
||
);
|
||
|
||
expect(workers).toBe(1);
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("forces single worker when html-in-canvas is detected", () => {
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const workers = resolveRenderWorkerCount(
|
||
900,
|
||
undefined,
|
||
cfg,
|
||
{
|
||
hasShaderTransitions: false,
|
||
renderModeHints: {
|
||
recommendScreenshot: false,
|
||
reasons: [{ code: "htmlInCanvas", message: "layoutsubtree canvas" }],
|
||
},
|
||
},
|
||
log,
|
||
);
|
||
|
||
expect(workers).toBe(1);
|
||
expect(log.warn).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("overrides explicit --workers when html-in-canvas is detected", () => {
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const workers = resolveRenderWorkerCount(
|
||
900,
|
||
8,
|
||
cfg,
|
||
{
|
||
hasShaderTransitions: false,
|
||
renderModeHints: {
|
||
recommendScreenshot: false,
|
||
reasons: [{ code: "htmlInCanvas", message: "layoutsubtree canvas" }],
|
||
},
|
||
},
|
||
log,
|
||
);
|
||
|
||
expect(workers).toBe(1);
|
||
expect(log.warn).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("pins to 1 worker in low-memory mode when no explicit --workers is set", () => {
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const workers = resolveRenderWorkerCount(
|
||
900,
|
||
undefined,
|
||
{ ...cfg, lowMemoryMode: true },
|
||
{
|
||
hasShaderTransitions: false,
|
||
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||
},
|
||
log,
|
||
);
|
||
|
||
expect(workers).toBe(1);
|
||
expect(log.info).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
// fallow-ignore-next-line code-duplication
|
||
it("respects explicit --workers in low-memory mode (only the pin is bypassed)", () => {
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const workers = resolveRenderWorkerCount(
|
||
900,
|
||
4,
|
||
{ ...cfg, lowMemoryMode: true, coresPerWorker: 2.5 },
|
||
{
|
||
hasShaderTransitions: false,
|
||
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||
},
|
||
log,
|
||
);
|
||
|
||
expect(workers).toBe(4);
|
||
});
|
||
|
||
it("keeps baseline auto workers after screenshot fallback when measured capture is cheap", () => {
|
||
const log = {
|
||
error: vi.fn(),
|
||
warn: vi.fn(),
|
||
info: vi.fn(),
|
||
debug: vi.fn(),
|
||
};
|
||
|
||
const stableCfg = {
|
||
...cfg,
|
||
concurrency: 2 as const,
|
||
largeRenderThreshold: 1_000,
|
||
};
|
||
const workers = resolveRenderWorkerCount(
|
||
180,
|
||
undefined,
|
||
{ ...stableCfg, forceScreenshot: true },
|
||
{
|
||
hasShaderTransitions: false,
|
||
renderModeHints: { recommendScreenshot: false, reasons: [] },
|
||
},
|
||
log,
|
||
{ multiplier: 1, reasons: [], p95Ms: 180 },
|
||
);
|
||
|
||
expect(workers).toBe(2);
|
||
expect(log.warn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("resolveCaptureForceScreenshotForPageSideCompositing", () => {
|
||
it("forces screenshot capture when page-side shader compositing is active", () => {
|
||
expect(
|
||
resolveCaptureForceScreenshotForPageSideCompositing({
|
||
forceScreenshot: false,
|
||
usePageSideCompositing: true,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("preserves the existing capture mode when page-side compositing is inactive", () => {
|
||
expect(
|
||
resolveCaptureForceScreenshotForPageSideCompositing({
|
||
forceScreenshot: false,
|
||
usePageSideCompositing: false,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
resolveCaptureForceScreenshotForPageSideCompositing({
|
||
forceScreenshot: true,
|
||
usePageSideCompositing: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("shouldDiscardProbeSessionForPageSideCompositing", () => {
|
||
it("discards a previously-loaded probe page when page-side compositing is selected", () => {
|
||
expect(
|
||
shouldDiscardProbeSessionForPageSideCompositing({
|
||
hasProbeSession: true,
|
||
usePageSideCompositing: true,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("reuses the probe session when no page-side pre-head script is required", () => {
|
||
expect(
|
||
shouldDiscardProbeSessionForPageSideCompositing({
|
||
hasProbeSession: true,
|
||
usePageSideCompositing: false,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
shouldDiscardProbeSessionForPageSideCompositing({
|
||
hasProbeSession: false,
|
||
usePageSideCompositing: true,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("estimateCaptureCostMultiplier", () => {
|
||
it("weights shader transitions and render mode hints without charging static media cost", () => {
|
||
const cost = estimateCaptureCostMultiplier({
|
||
hasShaderTransitions: true,
|
||
renderModeHints: {
|
||
recommendScreenshot: true,
|
||
reasons: [{ code: "requestAnimationFrame", message: "raw rAF" }],
|
||
},
|
||
});
|
||
|
||
expect(cost.multiplier).toBe(4);
|
||
expect(cost.reasons).toEqual(["shader-transitions", "requestAnimationFrame"]);
|
||
});
|
||
});
|
||
|
||
describe("shouldUseLayeredComposite", () => {
|
||
it("uses the layered compositor for SDR shader transition renders", () => {
|
||
expect(
|
||
shouldUseLayeredComposite({
|
||
hasHdrContent: false,
|
||
hasShaderTransitions: true,
|
||
isPngSequence: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("does not route PNG sequence shader renders through the streaming layered compositor", () => {
|
||
expect(
|
||
shouldUseLayeredComposite({
|
||
hasHdrContent: false,
|
||
hasShaderTransitions: true,
|
||
isPngSequence: true,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("keeps HDR content on the layered compositor even without shader transitions", () => {
|
||
expect(
|
||
shouldUseLayeredComposite({
|
||
hasHdrContent: true,
|
||
hasShaderTransitions: false,
|
||
isPngSequence: false,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("resolveCompositeTransfer", () => {
|
||
it("uses 16-bit-expanded sRGB for SDR layered shader transition renders", () => {
|
||
expect(resolveCompositeTransfer(false, undefined)).toBe("srgb");
|
||
});
|
||
|
||
it("uses the active HDR transfer when HDR content is being preserved", () => {
|
||
expect(resolveCompositeTransfer(true, { transfer: "hlg" })).toBe("hlg");
|
||
});
|
||
});
|
||
|
||
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("caps protocol timeout at calibration ceiling for fast fallback", () => {
|
||
const cfg = createConfig();
|
||
const calibrationCfg = createCaptureCalibrationConfig(cfg);
|
||
|
||
// Default 300s is above the 30s calibration ceiling — cap at 30s
|
||
// so a wedged BeginFrame times out fast and falls back to screenshot
|
||
expect(calibrationCfg.protocolTimeout).toBe(30000);
|
||
expect(cfg.protocolTimeout).toBe(300000);
|
||
});
|
||
|
||
it("preserves user timeout when already below calibration ceiling", () => {
|
||
const cfg = createConfig();
|
||
cfg.protocolTimeout = 5000;
|
||
|
||
// 5s is below the 30s ceiling — keep the user's value
|
||
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);
|
||
});
|
||
|
||
it("falls back to screenshot mode after Runtime.callFunctionOn timeout during calibration", () => {
|
||
expect(
|
||
shouldFallbackToScreenshotAfterCalibrationError(
|
||
new Error(
|
||
"Runtime.callFunctionOn timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.",
|
||
),
|
||
),
|
||
).toBe(true);
|
||
expect(
|
||
shouldFallbackToScreenshotAfterCalibrationError(
|
||
new Error(
|
||
"Runtime.evaluate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.",
|
||
),
|
||
),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
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`),
|
||
"captured-frame",
|
||
);
|
||
}
|
||
|
||
expect(findMissingFrameRanges(6, framesDir, "jpg")).toEqual([
|
||
{ startFrame: 2, endFrame: 4 },
|
||
{ startFrame: 5, endFrame: 6 },
|
||
]);
|
||
});
|
||
|
||
it("retries a worker placeholder instead of accepting a truncated sequence", () => {
|
||
const framesDir = makeFramesDir();
|
||
for (let frameIndex = 0; frameIndex < 4; frameIndex++) {
|
||
writeFileSync(
|
||
join(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.jpg`),
|
||
frameIndex === 2 ? "x" : "captured-frame",
|
||
);
|
||
}
|
||
|
||
expect(findMissingFrameRanges(4, framesDir, "jpg")).toEqual([{ startFrame: 2, endFrame: 3 }]);
|
||
});
|
||
|
||
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(
|
||
"[Parallel] Capture failed: Worker 0: drawElement worker encode timed out (frame 42)",
|
||
),
|
||
),
|
||
).toBe(true);
|
||
expect(isRecoverableParallelCaptureError(new Error("Encoding failed: ffmpeg exited"))).toBe(
|
||
false,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("projectBrowserEndToCompositionTimeline", () => {
|
||
it("keeps end unchanged when browser and compiled starts share the same origin", () => {
|
||
expect(projectBrowserEndToCompositionTimeline(2, 2, 6)).toBe(6);
|
||
});
|
||
|
||
it("reprojects a scene-local browser end into the compiled host timeline", () => {
|
||
expect(projectBrowserEndToCompositionTimeline(4.417, 0, 85.52)).toBeCloseTo(89.937, 6);
|
||
});
|
||
|
||
it("preserves scene-local media offsets inside compositions that start much later", () => {
|
||
expect(projectBrowserEndToCompositionTimeline(21.5, 1.5, 5.5)).toBe(25.5);
|
||
});
|
||
});
|
||
|
||
describe("resolveDeviceScaleFactor", () => {
|
||
const defaults = {
|
||
compositionWidth: 1920,
|
||
compositionHeight: 1080,
|
||
hdrRequested: false,
|
||
alphaRequested: false,
|
||
} as const;
|
||
|
||
it("returns 1 when no outputResolution is set (default behavior)", () => {
|
||
expect(resolveDeviceScaleFactor({ ...defaults, outputResolution: undefined })).toBe(1);
|
||
});
|
||
|
||
it("returns 2 for the canonical 1080p → 4K supersample", () => {
|
||
expect(resolveDeviceScaleFactor({ ...defaults, outputResolution: "landscape-4k" })).toBe(2);
|
||
});
|
||
|
||
it("returns 2 for portrait 1080p → portrait-4k", () => {
|
||
expect(
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 1080,
|
||
compositionHeight: 1920,
|
||
outputResolution: "portrait-4k",
|
||
}),
|
||
).toBe(2);
|
||
});
|
||
|
||
it("returns 1 when the composition already matches the requested resolution", () => {
|
||
expect(
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 3840,
|
||
compositionHeight: 2160,
|
||
outputResolution: "landscape-4k",
|
||
}),
|
||
).toBe(1);
|
||
});
|
||
|
||
it("rejects HDR + outputResolution with a clear message", () => {
|
||
expect(() =>
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
outputResolution: "landscape-4k",
|
||
hdrRequested: true,
|
||
}),
|
||
).toThrow(/hdrMode='force-hdr'/);
|
||
});
|
||
|
||
it("rejects alpha + outputResolution (the alpha capture path doesn't apply DPR yet)", () => {
|
||
expect(() =>
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
outputResolution: "landscape-4k",
|
||
alphaRequested: true,
|
||
}),
|
||
).toThrow(/alpha output/);
|
||
});
|
||
|
||
it("rejects orientation mismatch (landscape comp → portrait-4k)", () => {
|
||
expect(() =>
|
||
resolveDeviceScaleFactor({ ...defaults, outputResolution: "portrait-4k" }),
|
||
).toThrow(/aspect ratio/);
|
||
});
|
||
|
||
it("suggests the matching-orientation preset in the aspect-mismatch message", () => {
|
||
// Landscape composition + portrait preset → the message should point at
|
||
// the landscape swap so the user isn't left to guess (workstream P1-3).
|
||
expect(() => resolveDeviceScaleFactor({ ...defaults, outputResolution: "portrait" })).toThrow(
|
||
/--resolution landscape/,
|
||
);
|
||
});
|
||
|
||
it("rejects downsampling (4K composition → 1080p output)", () => {
|
||
expect(() =>
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 3840,
|
||
compositionHeight: 2160,
|
||
outputResolution: "landscape",
|
||
}),
|
||
).toThrow(/Downsampling/);
|
||
});
|
||
|
||
it("rejects non-integer scale factors", () => {
|
||
// 1500×844 → 3840×2160 has slightly different ratios in width vs height.
|
||
// The aspect-ratio guard fires first; pinning the rejection message
|
||
// covers both error paths since either is an acceptable failure here.
|
||
expect(() =>
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 1500,
|
||
compositionHeight: 844,
|
||
outputResolution: "landscape-4k",
|
||
}),
|
||
).toThrow(/aspect ratio|non-integer/);
|
||
});
|
||
|
||
it("returns 1 for a square comp matching the square preset", () => {
|
||
expect(
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 1080,
|
||
compositionHeight: 1080,
|
||
outputResolution: "square",
|
||
}),
|
||
).toBe(1);
|
||
});
|
||
|
||
it("returns 2 for square 1080 → square-4k", () => {
|
||
expect(
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 1080,
|
||
compositionHeight: 1080,
|
||
outputResolution: "square-4k",
|
||
}),
|
||
).toBe(2);
|
||
});
|
||
|
||
it("rejects landscape preset on a square composition", () => {
|
||
expect(() =>
|
||
resolveDeviceScaleFactor({
|
||
...defaults,
|
||
compositionWidth: 1080,
|
||
compositionHeight: 1080,
|
||
outputResolution: "landscape",
|
||
}),
|
||
).toThrow(/aspect ratio/);
|
||
});
|
||
});
|
||
|
||
describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => {
|
||
const eligible = {
|
||
workerCount: 5,
|
||
requestedWorkers: "auto" as const,
|
||
useDrawElement: true,
|
||
deCompileGate: undefined,
|
||
forceScreenshot: false,
|
||
outputFormat: "mp4" as const,
|
||
totalFrames: 2380,
|
||
minFrames: 900,
|
||
singleWorkerStreamingOk: true,
|
||
layeredOrEffectRoute: false,
|
||
supersampling: false,
|
||
probeDeGated: false,
|
||
experimentalParallelDeOptIn: false,
|
||
};
|
||
|
||
it("inverts an auto-resolved multi-worker render for an eligible long comp", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement(eligible)).toBe(true);
|
||
});
|
||
|
||
it("honors explicitly requested workers", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, requestedWorkers: 3 })).toBe(false);
|
||
});
|
||
|
||
it("inverts for requestedWorkers undefined — the value production actually passes for auto", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, requestedWorkers: undefined })).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("skips comps routed to layered/HDR/shader paths (drawElement never runs there)", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, layeredOrEffectRoute: true })).toBe(
|
||
false,
|
||
);
|
||
});
|
||
|
||
it("skips supersampled renders (engine init-time DE gate)", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, supersampling: true })).toBe(false);
|
||
});
|
||
|
||
it("skips when the probe session already shows DE gated out", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, probeDeGated: true })).toBe(false);
|
||
});
|
||
|
||
it("honors the explicit experimental parallel-DE opt-in", () => {
|
||
expect(
|
||
shouldPreferSingleWorkerDrawElement({ ...eligible, experimentalParallelDeOptIn: true }),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("skips below the amortization threshold (measured crossover ~900 frames)", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, totalFrames: 360 })).toBe(false);
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, totalFrames: 900 })).toBe(true);
|
||
});
|
||
|
||
it("is disabled by minFrames <= 0 (HF_DE_SINGLE_MIN_FRAMES=0 kill switch)", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, minFrames: 0 })).toBe(false);
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, minFrames: -1 })).toBe(false);
|
||
});
|
||
|
||
it("requires drawElement to be enabled and ungated", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, useDrawElement: false })).toBe(false);
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, deCompileGate: "3d" })).toBe(false);
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, forceScreenshot: true })).toBe(false);
|
||
});
|
||
|
||
it("only applies to the benchmarked configuration (mp4 + streaming-eligible)", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, outputFormat: "webm" })).toBe(false);
|
||
expect(
|
||
shouldPreferSingleWorkerDrawElement({ ...eligible, singleWorkerStreamingOk: false }),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("is a no-op when workers already resolved to 1", () => {
|
||
expect(shouldPreferSingleWorkerDrawElement({ ...eligible, workerCount: 1 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("resolveInversionRetryPlan (self-verify retry rollback)", () => {
|
||
const cfg = { enableStreamingEncode: true, streamingEncodeMaxDurationSeconds: 240 };
|
||
|
||
it("returns null when the render was never inverted", () => {
|
||
expect(
|
||
resolveInversionRetryPlan({
|
||
deWorkerInversion: undefined,
|
||
preInversionWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
}),
|
||
).toBe(null);
|
||
expect(
|
||
resolveInversionRetryPlan({
|
||
deWorkerInversion: "reverted",
|
||
preInversionWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
}),
|
||
).toBe(null);
|
||
});
|
||
|
||
it("restores the pre-inversion worker count and routes multi-worker retries to disk", () => {
|
||
const plan = resolveInversionRetryPlan({
|
||
deWorkerInversion: "inverted",
|
||
preInversionWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
});
|
||
expect(plan).toEqual({
|
||
workerCount: 5,
|
||
// shouldUseStreamingEncode is workerCount===1-only — parallel retry
|
||
// goes through the disk path.
|
||
useStreamingEncode: false,
|
||
deWorkerInversion: "reverted",
|
||
});
|
||
});
|
||
|
||
it("keeps streaming when the pre-inversion resolution was already single-worker", () => {
|
||
const plan = resolveInversionRetryPlan({
|
||
deWorkerInversion: "inverted",
|
||
preInversionWorkerCount: 1,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
});
|
||
expect(plan).toEqual({
|
||
workerCount: 1,
|
||
useStreamingEncode: true,
|
||
deWorkerInversion: "reverted",
|
||
});
|
||
});
|
||
|
||
it("drops to a single worker on OOM regardless of the pre-inversion count (the actual memory remedy)", () => {
|
||
const plan = resolveInversionRetryPlan({
|
||
deWorkerInversion: "inverted",
|
||
preInversionWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: true,
|
||
});
|
||
expect(plan).toEqual({
|
||
workerCount: 1,
|
||
useStreamingEncode: true,
|
||
deWorkerInversion: "reverted",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("shouldPreferParallelDrawElement (DE parallel router)", () => {
|
||
const eligible = {
|
||
workerCount: 5,
|
||
requestedWorkers: "auto" as const,
|
||
useDrawElement: true,
|
||
deCompileGate: undefined,
|
||
forceScreenshot: false,
|
||
outputFormat: "mp4" as const,
|
||
totalFrames: 2381,
|
||
minFrames: 2000,
|
||
layeredOrEffectRoute: false,
|
||
supersampling: false,
|
||
probeDeGated: false,
|
||
experimentalParallelDeOptIn: false,
|
||
routerEnabled: true,
|
||
parallelStreamingAvailable: true,
|
||
totalMemoryMb: 32768,
|
||
minMemoryMb: 24576,
|
||
};
|
||
|
||
it("routes an auto-resolved multi-worker render for an eligible long comp", () => {
|
||
expect(shouldPreferParallelDrawElement(eligible)).toBe(true);
|
||
});
|
||
|
||
it("withholds the bet when parallel streaming can't run (e.g. over the duration cap)", () => {
|
||
// The router pins workerCount to 3 and skips calibration to serve the
|
||
// verified parallel DE STREAMING path. If streaming is off for this
|
||
// render — the >240s duration cap is the common case — firing would pay
|
||
// the whole cost of the pin for none of the benefit.
|
||
expect(
|
||
shouldPreferParallelDrawElement({ ...eligible, parallelStreamingAvailable: false }),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("withholds the parallel bet below the RAM floor (16 GB black-slab report)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, totalMemoryMb: 16384 })).toBe(false);
|
||
});
|
||
|
||
it("routes exactly at the RAM floor", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, totalMemoryMb: 24576 })).toBe(true);
|
||
});
|
||
|
||
it("minMemoryMb <= 0 disables the RAM guard", () => {
|
||
expect(
|
||
shouldPreferParallelDrawElement({ ...eligible, totalMemoryMb: 8192, minMemoryMb: 0 }),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("is disabled by default (routerEnabled: false is the shipped default)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, routerEnabled: false })).toBe(false);
|
||
});
|
||
|
||
it("honors explicitly requested workers", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, requestedWorkers: 3 })).toBe(false);
|
||
});
|
||
|
||
it("routes for requestedWorkers undefined — the value production actually passes for auto", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, requestedWorkers: undefined })).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("skips comps routed to layered/HDR/shader paths (drawElement never runs there)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, layeredOrEffectRoute: true })).toBe(
|
||
false,
|
||
);
|
||
});
|
||
|
||
it("skips supersampled renders (engine init-time DE gate)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, supersampling: true })).toBe(false);
|
||
});
|
||
|
||
it("skips when the probe session already shows DE gated out", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, probeDeGated: true })).toBe(false);
|
||
});
|
||
|
||
it("honors the explicit experimental parallel-DE opt-in (already parallel, router is a no-op)", () => {
|
||
expect(
|
||
shouldPreferParallelDrawElement({ ...eligible, experimentalParallelDeOptIn: true }),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("skips below the amortization threshold (benchmark: real-work comps clear 1.25x at ~2,000+ frames)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, totalFrames: 915 })).toBe(false);
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, totalFrames: 2000 })).toBe(true);
|
||
});
|
||
|
||
it("is disabled by minFrames <= 0 (HF_DE_PARALLEL_MIN_FRAMES=0 kill switch)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, minFrames: 0 })).toBe(false);
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, minFrames: -1 })).toBe(false);
|
||
});
|
||
|
||
it("requires drawElement to be enabled and ungated", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, useDrawElement: false })).toBe(false);
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, deCompileGate: "3d" })).toBe(false);
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, forceScreenshot: true })).toBe(false);
|
||
});
|
||
|
||
it("only applies to mp4 (the benchmarked configuration)", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, outputFormat: "webm" })).toBe(false);
|
||
});
|
||
|
||
it("is a no-op when workers already resolved to 1", () => {
|
||
expect(shouldPreferParallelDrawElement({ ...eligible, workerCount: 1 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("resolveParallelRouterRetryPlan (self-verify retry rollback)", () => {
|
||
const cfg = { enableStreamingEncode: true, streamingEncodeMaxDurationSeconds: 240 };
|
||
|
||
it("returns null when the render was never router-routed", () => {
|
||
expect(
|
||
resolveParallelRouterRetryPlan({
|
||
deParallelRouter: undefined,
|
||
preRouterWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
}),
|
||
).toBe(null);
|
||
expect(
|
||
resolveParallelRouterRetryPlan({
|
||
deParallelRouter: "reverted",
|
||
preRouterWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
}),
|
||
).toBe(null);
|
||
});
|
||
|
||
it("restores the pre-router worker count and routes multi-worker retries to disk", () => {
|
||
const plan = resolveParallelRouterRetryPlan({
|
||
deParallelRouter: "routed",
|
||
preRouterWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: false,
|
||
});
|
||
expect(plan).toEqual({
|
||
workerCount: 5,
|
||
useStreamingEncode: false,
|
||
deParallelRouter: "reverted",
|
||
});
|
||
});
|
||
|
||
it("drops to a single worker on OOM regardless of the pre-router count (the actual memory remedy)", () => {
|
||
const plan = resolveParallelRouterRetryPlan({
|
||
deParallelRouter: "routed",
|
||
preRouterWorkerCount: 5,
|
||
cfg,
|
||
outputFormat: "mp4",
|
||
durationSeconds: 80,
|
||
isMemoryExhaustion: true,
|
||
});
|
||
expect(plan).toEqual({
|
||
workerCount: 1,
|
||
useStreamingEncode: true,
|
||
deParallelRouter: "reverted",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("shouldRetryViaPinnedFallback (widen the self-verify retry to generic capture failures, including OOM)", () => {
|
||
it("always retries a drawElement self-verify failure, pinned or not", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: true,
|
||
isCancellation: false,
|
||
deWorkerInversion: undefined,
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("retries a generic capture failure when the router pinned the worker count", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: false,
|
||
deWorkerInversion: undefined,
|
||
deParallelRouter: "routed",
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("retries a generic capture failure when the inversion pinned the worker count", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: false,
|
||
deWorkerInversion: "inverted",
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("does not retry a generic capture failure when nothing pinned the worker count", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: false,
|
||
deWorkerInversion: undefined,
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("retries OOM too when the router pinned the worker count (fallback's Chrome processes are already dead by the time this runs, and the fallback is pooled/lighter than the pinned path)", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: false,
|
||
deWorkerInversion: undefined,
|
||
deParallelRouter: "routed",
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("retries OOM too when the inversion pinned the worker count", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: false,
|
||
deWorkerInversion: "inverted",
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("does not retry a generic failure on an already-reverted cohort (no pin left to retreat from)", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: false,
|
||
deWorkerInversion: "reverted",
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("never retries a cancellation, even on a pinned cohort — must propagate immediately, not detour through a fresh encoder spin-up", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: true,
|
||
deWorkerInversion: "inverted",
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: false,
|
||
isCancellation: true,
|
||
deWorkerInversion: undefined,
|
||
deParallelRouter: "routed",
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it("cancellation wins even if the error also looks like a self-verify failure", () => {
|
||
expect(
|
||
shouldRetryViaPinnedFallback({
|
||
isVerifyError: true,
|
||
isCancellation: true,
|
||
deWorkerInversion: undefined,
|
||
deParallelRouter: undefined,
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("shouldStreamParallelCapture (non-DE parallel streaming router)", () => {
|
||
const eligible = {
|
||
routerEnabled: true,
|
||
workerCount: 3,
|
||
useDrawElement: false,
|
||
outputFormat: "mp4" as const,
|
||
streamingOk: true,
|
||
layeredOrEffectRoute: false,
|
||
};
|
||
|
||
it("routes an eligible multi-worker non-drawElement render", () => {
|
||
expect(shouldStreamParallelCapture(eligible)).toBe(true);
|
||
});
|
||
|
||
it("is disabled by default (kill switch off is the shipped default)", () => {
|
||
expect(shouldStreamParallelCapture({ ...eligible, routerEnabled: false })).toBe(false);
|
||
});
|
||
|
||
it("never fires for single-worker renders (those already stream)", () => {
|
||
expect(shouldStreamParallelCapture({ ...eligible, workerCount: 1 })).toBe(false);
|
||
});
|
||
|
||
it("never fires when drawElement will capture (the DE routers own that path)", () => {
|
||
expect(shouldStreamParallelCapture({ ...eligible, useDrawElement: true })).toBe(false);
|
||
});
|
||
|
||
it("only applies to mp4", () => {
|
||
expect(shouldStreamParallelCapture({ ...eligible, outputFormat: "webm" })).toBe(false);
|
||
expect(shouldStreamParallelCapture({ ...eligible, outputFormat: "png-sequence" })).toBe(false);
|
||
});
|
||
|
||
it("respects the streaming-encode config/duration gates", () => {
|
||
expect(shouldStreamParallelCapture({ ...eligible, streamingOk: false })).toBe(false);
|
||
});
|
||
|
||
it("skips HDR-layered and shader-transition routes", () => {
|
||
expect(shouldStreamParallelCapture({ ...eligible, layeredOrEffectRoute: true })).toBe(false);
|
||
});
|
||
});
|