Files
hyperframes/packages/producer/src/services/renderOrchestrator.test.ts
T

832 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join, win32 } from "node:path";
import { tmpdir } from "node:os";
import type { EngineConfig, ExtractedFrames } from "@hyperframes/engine";
import type { CompiledComposition } from "./htmlCompiler.js";
import {
applyRenderModeHints,
buildMissingFrameRetryBatches,
collectVideoMetadataHints,
collectVideoReadinessSkipIds,
createCaptureCalibrationConfig,
createCompiledFrameSrcResolver,
estimateMeasuredCaptureCostMultiplier,
estimateCaptureCostMultiplier,
extractStandaloneEntryFromIndex,
findMissingFrameRanges,
getNextRetryWorkerCount,
isRecoverableParallelCaptureError,
materializeExtractedFramesForCompiledDir,
projectBrowserEndToCompositionTimeline,
resolveDeviceScaleFactor,
resolveRenderWorkerCount,
resolveCompositeTransfer,
selectCaptureCalibrationFrames,
shouldFallbackToScreenshotAfterCalibrationError,
shouldUseLayeredComposite,
shouldUseStreamingEncode,
writeCompiledArtifacts,
} from "./renderOrchestrator.js";
import { toExternalAssetKey } from "../utils/paths.js";
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();
});
});
describe("shouldUseStreamingEncode", () => {
const streamingEnabledConfig = {
enableStreamingEncode: true,
streamingEncodeMaxDurationSeconds: 240,
};
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("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);
});
});
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");
},
},
});
expect(extracted.outputDir).toBe(outputDir);
expect(extracted.framePaths.get(0)).toBe(framePath);
});
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 }> = [];
materializeExtractedFramesForCompiledDir([extracted], compiledDir, {
pathModule: win32,
fileSystem: {
existsSync: () => false,
mkdirSync: () => undefined,
symlinkSync: (target, path) => {
symlinks.push({ target, path });
},
},
});
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 }]);
});
});
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 workDir = makeWorkDir();
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,
};
}
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,
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", () => {
it("forces screenshot mode when compatibility hints recommend it", () => {
const cfg = createConfig();
const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]);
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
applyRenderModeHints(cfg, compiled, log);
expect(cfg.forceScreenshot).toBe(true);
expect(log.warn).toHaveBeenCalledOnce();
});
it("does nothing when screenshot mode is already forced", () => {
const cfg = createConfig();
cfg.forceScreenshot = true;
const compiled = createCompiledComposition(["iframe"]);
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
applyRenderModeHints(cfg, compiled, log);
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);
});
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 workers = resolveRenderWorkerCount(
180,
undefined,
{ ...cfg, forceScreenshot: true },
{
hasShaderTransitions: false,
renderModeHints: { recommendScreenshot: false, reasons: [] },
},
log,
{ multiplier: 1, reasons: [], p95Ms: 180 },
);
expect(workers).toBe(6);
expect(log.warn).not.toHaveBeenCalled();
});
});
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("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);
});
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`), "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", () => {
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,
} 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({
compositionWidth: 3840,
compositionHeight: 2160,
outputResolution: "landscape-4k",
hdrRequested: false,
}),
).toBe(1);
});
it("rejects HDR + outputResolution with a clear message", () => {
expect(() =>
resolveDeviceScaleFactor({
...defaults,
outputResolution: "landscape-4k",
hdrRequested: true,
}),
).toThrow(/hdrMode='force-hdr'/);
});
it("rejects orientation mismatch (landscape comp → portrait-4k)", () => {
expect(() =>
resolveDeviceScaleFactor({ ...defaults, outputResolution: "portrait-4k" }),
).toThrow(/aspect ratio/);
});
it("rejects downsampling (4K composition → 1080p output)", () => {
expect(() =>
resolveDeviceScaleFactor({
compositionWidth: 3840,
compositionHeight: 2160,
outputResolution: "landscape",
hdrRequested: false,
}),
).toThrow(/Downsampling/);
});
it("rejects non-integer scale factors", () => {
// 1280×720 → 3840×2160 would be 3×, but width 1280 → 3840 is also 3× — that's actually integer.
// Use 1280×720 → 2160×3840 (mismatched orientation triggers aspect first), so use a real
// non-integer: 1500×844 → 3840×2160 = 2.56×.
expect(() =>
resolveDeviceScaleFactor({
compositionWidth: 1500,
compositionHeight: 844,
outputResolution: "landscape-4k",
hdrRequested: false,
}),
).toThrow(/aspect ratio|non-integer/);
});
});