@hyperframes/engine 0.6.118 → 0.6.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -7
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { float16ToPqRgb } from "./hdrCapture.js";
|
|
3
|
-
|
|
4
|
-
// IEEE 754 half-precision (float16) bit patterns used to feed
|
|
5
|
-
// `float16ToPqRgb`. Encoding rule: sign(1) | exp(5) | frac(10).
|
|
6
|
-
const F16_ZERO = 0x0000; // +0.0
|
|
7
|
-
const F16_HALF = 0x3800; // +0.5 (exp=14, frac=0 → 2^-1)
|
|
8
|
-
const F16_ONE = 0x3c00; // +1.0 (exp=15, frac=0 → 2^0 — SDR white)
|
|
9
|
-
// PQ caps at 10000 nits and SDR_NITS = 203, so the linear input must exceed
|
|
10
|
-
// ~58x SDR white before linearToPQ(L) clips at 1.0. 1024 is well above that.
|
|
11
|
-
const F16_OVERBRIGHT = 0x6400; // +1024.0 (exp=25, frac=0 → 2^10)
|
|
12
|
-
|
|
13
|
-
function makeFloat16Frame(
|
|
14
|
-
width: number,
|
|
15
|
-
height: number,
|
|
16
|
-
pixel: { r: number; g: number; b: number; a: number },
|
|
17
|
-
bytesPerRow: number = width * 8,
|
|
18
|
-
): Buffer {
|
|
19
|
-
// Row-padded layout matches WebGPU readback: bytesPerRow ≥ width * 8 (4
|
|
20
|
-
// channels × 2 bytes), with garbage bytes after each row's pixel data.
|
|
21
|
-
const buf = Buffer.alloc(height * bytesPerRow);
|
|
22
|
-
for (let y = 0; y < height; y++) {
|
|
23
|
-
for (let x = 0; x < width; x++) {
|
|
24
|
-
const idx = y * bytesPerRow + x * 8;
|
|
25
|
-
buf.writeUInt16LE(pixel.r, idx);
|
|
26
|
-
buf.writeUInt16LE(pixel.g, idx + 2);
|
|
27
|
-
buf.writeUInt16LE(pixel.b, idx + 4);
|
|
28
|
-
buf.writeUInt16LE(pixel.a, idx + 6);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return buf;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("float16ToPqRgb", () => {
|
|
35
|
-
it("returns a buffer of width * height * 6 bytes (rgb48le)", () => {
|
|
36
|
-
const frame = makeFloat16Frame(4, 3, { r: 0, g: 0, b: 0, a: 0 });
|
|
37
|
-
const out = float16ToPqRgb(frame, 32, 4, 3);
|
|
38
|
-
expect(out.length).toBe(4 * 3 * 6);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("encodes float16 black to PQ zero (linearToPQ(0) ≈ 0 after uint16 quantization)", () => {
|
|
42
|
-
const frame = makeFloat16Frame(2, 2, {
|
|
43
|
-
r: F16_ZERO,
|
|
44
|
-
g: F16_ZERO,
|
|
45
|
-
b: F16_ZERO,
|
|
46
|
-
a: F16_ZERO,
|
|
47
|
-
});
|
|
48
|
-
const out = float16ToPqRgb(frame, 16, 2, 2);
|
|
49
|
-
for (let i = 0; i < out.length; i += 2) {
|
|
50
|
-
expect(out.readUInt16LE(i)).toBe(0);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("clamps overbright float16 input to PQ 65535 (linearToPQ(>>1.0) → 1.0)", () => {
|
|
55
|
-
// ~1024 linear is well past the 58x-SDR PQ saturation point; output caps
|
|
56
|
-
// at 1.0 → 65535 in uint16.
|
|
57
|
-
const frame = makeFloat16Frame(2, 2, {
|
|
58
|
-
r: F16_OVERBRIGHT,
|
|
59
|
-
g: F16_OVERBRIGHT,
|
|
60
|
-
b: F16_OVERBRIGHT,
|
|
61
|
-
a: F16_ZERO,
|
|
62
|
-
});
|
|
63
|
-
const out = float16ToPqRgb(frame, 16, 2, 2);
|
|
64
|
-
for (let pixel = 0; pixel < 4; pixel++) {
|
|
65
|
-
const dst = pixel * 6;
|
|
66
|
-
expect(out.readUInt16LE(dst)).toBe(65535);
|
|
67
|
-
expect(out.readUInt16LE(dst + 2)).toBe(65535);
|
|
68
|
-
expect(out.readUInt16LE(dst + 4)).toBe(65535);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("preserves channel ordering R, G, B (alpha is discarded)", () => {
|
|
73
|
-
// Distinct float16 values per channel verify the function doesn't
|
|
74
|
-
// mix them up. Alpha is set high but should not appear in the output.
|
|
75
|
-
const frame = makeFloat16Frame(1, 1, {
|
|
76
|
-
r: F16_ONE,
|
|
77
|
-
g: F16_HALF,
|
|
78
|
-
b: F16_ZERO,
|
|
79
|
-
a: F16_ONE,
|
|
80
|
-
});
|
|
81
|
-
const out = float16ToPqRgb(frame, 8, 1, 1);
|
|
82
|
-
const r = out.readUInt16LE(0);
|
|
83
|
-
const g = out.readUInt16LE(2);
|
|
84
|
-
const b = out.readUInt16LE(4);
|
|
85
|
-
expect(r).toBeGreaterThan(g);
|
|
86
|
-
expect(g).toBeGreaterThan(b);
|
|
87
|
-
expect(b).toBe(0);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("is monotonic: higher float16 input produces higher PQ output", () => {
|
|
91
|
-
const dark = makeFloat16Frame(1, 1, { r: F16_ZERO, g: 0, b: 0, a: 0 });
|
|
92
|
-
const mid = makeFloat16Frame(1, 1, { r: F16_HALF, g: 0, b: 0, a: 0 });
|
|
93
|
-
const bright = makeFloat16Frame(1, 1, { r: F16_ONE, g: 0, b: 0, a: 0 });
|
|
94
|
-
const r0 = float16ToPqRgb(dark, 8, 1, 1).readUInt16LE(0);
|
|
95
|
-
const r1 = float16ToPqRgb(mid, 8, 1, 1).readUInt16LE(0);
|
|
96
|
-
const r2 = float16ToPqRgb(bright, 8, 1, 1).readUInt16LE(0);
|
|
97
|
-
expect(r0).toBe(0);
|
|
98
|
-
expect(r1).toBeGreaterThan(r0);
|
|
99
|
-
expect(r2).toBeGreaterThan(r1);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("is deterministic across calls with the same input", () => {
|
|
103
|
-
const frame = makeFloat16Frame(3, 2, {
|
|
104
|
-
r: F16_HALF,
|
|
105
|
-
g: F16_ONE,
|
|
106
|
-
b: F16_ZERO,
|
|
107
|
-
a: F16_ONE,
|
|
108
|
-
});
|
|
109
|
-
const a = float16ToPqRgb(frame, 24, 3, 2);
|
|
110
|
-
const b = float16ToPqRgb(frame, 24, 3, 2);
|
|
111
|
-
expect(a.equals(b)).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("handles padded bytesPerRow (WebGPU 256-byte alignment)", () => {
|
|
115
|
-
// WebGPU readback pads rows to 256-byte multiples. For a 4-pixel-wide
|
|
116
|
-
// frame the actual pixel data is 32 bytes but bytesPerRow is 256.
|
|
117
|
-
const width = 4;
|
|
118
|
-
const height = 2;
|
|
119
|
-
const bytesPerRow = 256;
|
|
120
|
-
const frame = makeFloat16Frame(
|
|
121
|
-
width,
|
|
122
|
-
height,
|
|
123
|
-
{ r: F16_HALF, g: F16_HALF, b: F16_HALF, a: 0 },
|
|
124
|
-
bytesPerRow,
|
|
125
|
-
);
|
|
126
|
-
const out = float16ToPqRgb(frame, bytesPerRow, width, height);
|
|
127
|
-
expect(out.length).toBe(width * height * 6);
|
|
128
|
-
// Every R component should be the same non-zero value (uniform input).
|
|
129
|
-
const expected = out.readUInt16LE(0);
|
|
130
|
-
expect(expected).toBeGreaterThan(0);
|
|
131
|
-
for (let pixel = 0; pixel < width * height; pixel++) {
|
|
132
|
-
expect(out.readUInt16LE(pixel * 6)).toBe(expected);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("ignores garbage bytes in the row padding region", () => {
|
|
137
|
-
// Stuff junk into the trailing padding to make sure the PQ encoder
|
|
138
|
-
// walks via bytesPerRow stride and not via raw buffer position.
|
|
139
|
-
const width = 2;
|
|
140
|
-
const height = 2;
|
|
141
|
-
const bytesPerRow = 64;
|
|
142
|
-
const frame = makeFloat16Frame(
|
|
143
|
-
width,
|
|
144
|
-
height,
|
|
145
|
-
{ r: F16_ZERO, g: F16_ZERO, b: F16_ZERO, a: F16_ZERO },
|
|
146
|
-
bytesPerRow,
|
|
147
|
-
);
|
|
148
|
-
for (let y = 0; y < height; y++) {
|
|
149
|
-
const padStart = y * bytesPerRow + width * 8;
|
|
150
|
-
for (let i = padStart; i < (y + 1) * bytesPerRow; i++) {
|
|
151
|
-
frame[i] = 0xff;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const out = float16ToPqRgb(frame, bytesPerRow, width, height);
|
|
155
|
-
for (let i = 0; i < out.length; i += 2) {
|
|
156
|
-
expect(out.readUInt16LE(i)).toBe(0);
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
});
|
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
/// <reference types="@webgpu/types" />
|
|
2
|
-
/**
|
|
3
|
-
* HDR Capture Service
|
|
4
|
-
*
|
|
5
|
-
* Captures HDR video frames via WebGPU float16 readback.
|
|
6
|
-
*
|
|
7
|
-
* The pipeline:
|
|
8
|
-
* 1. FFmpeg extracts raw HDR pixels (rgba64le) from video sources
|
|
9
|
-
* 2. Node converts HLG/PQ signal → linear light → float16
|
|
10
|
-
* 3. writeTexture uploads float16 data to WebGPU rgba16float texture
|
|
11
|
-
* 4. (Optional) WebGPU shader applies GSAP CSS transform
|
|
12
|
-
* 5. readback extracts float16 RGBA via base64 transfer
|
|
13
|
-
* 6. Node converts linear float16 → PQ signal → pipe to FFmpeg H.265
|
|
14
|
-
*
|
|
15
|
-
* Requirements:
|
|
16
|
-
* - Headed Chrome (not headless) — WebGPU unavailable in headless mode
|
|
17
|
-
* - GPU access (Metal on macOS, Vulkan+NVIDIA on Linux)
|
|
18
|
-
*
|
|
19
|
-
* Performance: ~6 fps at 1080x1920 via base64 transfer.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type { Page, Browser, PuppeteerNode } from "puppeteer-core";
|
|
23
|
-
import { existsSync, readdirSync } from "fs";
|
|
24
|
-
import { join } from "path";
|
|
25
|
-
import { homedir } from "os";
|
|
26
|
-
|
|
27
|
-
// ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
const PQ_M1 = 0.1593017578125;
|
|
30
|
-
const PQ_M2 = 78.84375;
|
|
31
|
-
const PQ_C1 = 0.8359375;
|
|
32
|
-
const PQ_C2 = 18.8515625;
|
|
33
|
-
const PQ_C3 = 18.6875;
|
|
34
|
-
const PQ_MAX_NITS = 10000.0;
|
|
35
|
-
const SDR_NITS = 203.0;
|
|
36
|
-
|
|
37
|
-
function linearToPQ(L: number): number {
|
|
38
|
-
const Lp = Math.max(0, (L * SDR_NITS) / PQ_MAX_NITS);
|
|
39
|
-
const Lm1 = Math.pow(Lp, PQ_M1);
|
|
40
|
-
return Math.pow((PQ_C1 + PQ_C2 * Lm1) / (1.0 + PQ_C3 * Lm1), PQ_M2);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function float16Decode(h: number): number {
|
|
44
|
-
const sign = (h >> 15) & 1;
|
|
45
|
-
const exp = (h >> 10) & 0x1f;
|
|
46
|
-
const frac = h & 0x3ff;
|
|
47
|
-
if (exp === 0) return (sign ? -1 : 1) * Math.pow(2, -14) * (frac / 1024);
|
|
48
|
-
if (exp === 31) return frac ? NaN : sign ? -Infinity : Infinity;
|
|
49
|
-
return (sign ? -1 : 1) * Math.pow(2, exp - 15) * (1 + frac / 1024);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ── Browser-side interface ────────────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
interface HdrCaptureRuntime {
|
|
55
|
-
uploadAndReadback(float16Base64: string): Promise<{ base64: string; bytesPerRow: number }>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── Initialization ────────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Inject the WebGPU HDR readback runtime into the page.
|
|
62
|
-
*
|
|
63
|
-
* Creates an rgba16float render texture that accepts writeTexture uploads
|
|
64
|
-
* and provides readback via base64 transfer.
|
|
65
|
-
*/
|
|
66
|
-
export async function initHdrReadback(page: Page, width: number, height: number): Promise<boolean> {
|
|
67
|
-
return page.evaluate(
|
|
68
|
-
async (w: number, h: number): Promise<boolean> => {
|
|
69
|
-
if (!navigator.gpu) return false;
|
|
70
|
-
|
|
71
|
-
const adapter = await navigator.gpu.requestAdapter();
|
|
72
|
-
if (!adapter) return false;
|
|
73
|
-
|
|
74
|
-
const device = await adapter.requestDevice();
|
|
75
|
-
|
|
76
|
-
const bytesPerPixel = 8; // rgba16float = 4 channels × 2 bytes
|
|
77
|
-
const bytesPerRow = Math.ceil((w * bytesPerPixel) / 256) * 256;
|
|
78
|
-
|
|
79
|
-
// Render texture — includes COPY_DST for writeTexture uploads
|
|
80
|
-
const renderTexture = device.createTexture({
|
|
81
|
-
size: [w, h],
|
|
82
|
-
format: "rgba16float",
|
|
83
|
-
usage:
|
|
84
|
-
GPUTextureUsage.RENDER_ATTACHMENT |
|
|
85
|
-
GPUTextureUsage.COPY_SRC |
|
|
86
|
-
GPUTextureUsage.COPY_DST |
|
|
87
|
-
GPUTextureUsage.TEXTURE_BINDING,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const readBuffer = device.createBuffer({
|
|
91
|
-
size: bytesPerRow * h,
|
|
92
|
-
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
const captureRuntime = {
|
|
96
|
-
device,
|
|
97
|
-
renderTexture,
|
|
98
|
-
readBuffer,
|
|
99
|
-
bytesPerRow,
|
|
100
|
-
width: w,
|
|
101
|
-
height: h,
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Upload pre-converted float16 RGBA data and read it back.
|
|
105
|
-
* The float16 data must be row-aligned to bytesPerRow.
|
|
106
|
-
*
|
|
107
|
-
* Input: base64-encoded Uint16Array (float16 RGBA, row-padded)
|
|
108
|
-
* Output: base64-encoded readback of the same texture
|
|
109
|
-
*/
|
|
110
|
-
async uploadAndReadback(
|
|
111
|
-
float16Base64: string,
|
|
112
|
-
): Promise<{ base64: string; bytesPerRow: number }> {
|
|
113
|
-
// Decode base64 → Uint8Array
|
|
114
|
-
const binary = atob(float16Base64);
|
|
115
|
-
const bytes = new Uint8Array(binary.length);
|
|
116
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
117
|
-
|
|
118
|
-
// Upload to texture
|
|
119
|
-
device.queue.writeTexture(
|
|
120
|
-
{ texture: renderTexture },
|
|
121
|
-
bytes.buffer,
|
|
122
|
-
{ bytesPerRow, rowsPerImage: h },
|
|
123
|
-
[w, h],
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// Readback
|
|
127
|
-
const encoder = device.createCommandEncoder();
|
|
128
|
-
encoder.copyTextureToBuffer(
|
|
129
|
-
{ texture: renderTexture },
|
|
130
|
-
{ buffer: readBuffer, bytesPerRow },
|
|
131
|
-
[w, h],
|
|
132
|
-
);
|
|
133
|
-
device.queue.submit([encoder.finish()]);
|
|
134
|
-
|
|
135
|
-
await readBuffer.mapAsync(GPUMapMode.READ);
|
|
136
|
-
const readBytes = new Uint8Array(readBuffer.getMappedRange().slice(0));
|
|
137
|
-
readBuffer.unmap();
|
|
138
|
-
|
|
139
|
-
// Base64 encode in chunks
|
|
140
|
-
let b64 = "";
|
|
141
|
-
const chunkSize = 32768;
|
|
142
|
-
for (let i = 0; i < readBytes.length; i += chunkSize) {
|
|
143
|
-
const slice = readBytes.subarray(i, Math.min(i + chunkSize, readBytes.length));
|
|
144
|
-
b64 += String.fromCharCode(...slice);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return { base64: btoa(b64), bytesPerRow };
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
(window as unknown as Record<string, unknown>).__hfHdrCapture = captureRuntime;
|
|
152
|
-
return true;
|
|
153
|
-
},
|
|
154
|
-
width,
|
|
155
|
-
height,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ── HDR frame conversion ──────────────────────────────────────────────────────
|
|
160
|
-
|
|
161
|
-
// ── Frame upload + readback ───────────────────────────────────────────────────
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Upload a float16 frame to WebGPU and read it back.
|
|
165
|
-
* Call after converting with convertHdrFrameToFloat16Base64.
|
|
166
|
-
*/
|
|
167
|
-
export async function uploadAndReadbackHdrFrame(
|
|
168
|
-
page: Page,
|
|
169
|
-
float16Base64: string,
|
|
170
|
-
): Promise<{ rawBuffer: Buffer; bytesPerRow: number }> {
|
|
171
|
-
const result = await page.evaluate(
|
|
172
|
-
async (b64: string): Promise<{ base64: string; bytesPerRow: number }> => {
|
|
173
|
-
const hdr = (window as unknown as Record<string, unknown>).__hfHdrCapture as
|
|
174
|
-
| HdrCaptureRuntime
|
|
175
|
-
| undefined;
|
|
176
|
-
if (!hdr) throw new Error("HDR capture not initialized");
|
|
177
|
-
return hdr.uploadAndReadback(b64);
|
|
178
|
-
},
|
|
179
|
-
float16Base64,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
rawBuffer: Buffer.from(result.base64, "base64"),
|
|
184
|
-
bytesPerRow: result.bytesPerRow,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ── PQ conversion ─────────────────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Convert float16 RGBA readback to PQ-encoded rgb48le for FFmpeg.
|
|
192
|
-
*/
|
|
193
|
-
export function float16ToPqRgb(
|
|
194
|
-
rawBuffer: Buffer,
|
|
195
|
-
bytesPerRow: number,
|
|
196
|
-
width: number,
|
|
197
|
-
height: number,
|
|
198
|
-
): Buffer {
|
|
199
|
-
const data = new Uint16Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength / 2);
|
|
200
|
-
const channelsPerRow = bytesPerRow / 2;
|
|
201
|
-
const output = Buffer.alloc(width * height * 6);
|
|
202
|
-
|
|
203
|
-
for (let y = 0; y < height; y++) {
|
|
204
|
-
for (let x = 0; x < width; x++) {
|
|
205
|
-
const srcIdx = y * channelsPerRow + x * 4;
|
|
206
|
-
const r = float16Decode(data[srcIdx] ?? 0);
|
|
207
|
-
const g = float16Decode(data[srcIdx + 1] ?? 0);
|
|
208
|
-
const b = float16Decode(data[srcIdx + 2] ?? 0);
|
|
209
|
-
|
|
210
|
-
const dstIdx = (y * width + x) * 6;
|
|
211
|
-
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(r)) * 65535), dstIdx);
|
|
212
|
-
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(g)) * 65535), dstIdx + 2);
|
|
213
|
-
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(b)) * 65535), dstIdx + 4);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return output;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ── Chrome launch ─────────────────────────────────────────────────────────────
|
|
221
|
-
|
|
222
|
-
function resolveHeadedChromePath(): string | undefined {
|
|
223
|
-
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome");
|
|
224
|
-
if (!existsSync(baseDir)) return undefined;
|
|
225
|
-
const versions = readdirSync(baseDir).sort().reverse();
|
|
226
|
-
for (const version of versions) {
|
|
227
|
-
const candidates = [
|
|
228
|
-
join(
|
|
229
|
-
baseDir,
|
|
230
|
-
version,
|
|
231
|
-
"chrome-mac-arm64",
|
|
232
|
-
"Google Chrome for Testing.app",
|
|
233
|
-
"Contents",
|
|
234
|
-
"MacOS",
|
|
235
|
-
"Google Chrome for Testing",
|
|
236
|
-
),
|
|
237
|
-
join(
|
|
238
|
-
baseDir,
|
|
239
|
-
version,
|
|
240
|
-
"chrome-mac-x64",
|
|
241
|
-
"Google Chrome for Testing.app",
|
|
242
|
-
"Contents",
|
|
243
|
-
"MacOS",
|
|
244
|
-
"Google Chrome for Testing",
|
|
245
|
-
),
|
|
246
|
-
join(baseDir, version, "chrome-linux64", "chrome"),
|
|
247
|
-
join(baseDir, version, "chrome-win64", "chrome.exe"),
|
|
248
|
-
];
|
|
249
|
-
for (const binary of candidates) {
|
|
250
|
-
if (existsSync(binary)) return binary;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return undefined;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Launch a headed Chrome browser with WebGPU enabled.
|
|
258
|
-
*/
|
|
259
|
-
export async function launchHdrBrowser(
|
|
260
|
-
width: number,
|
|
261
|
-
height: number,
|
|
262
|
-
): Promise<{ browser: Browser; page: Page }> {
|
|
263
|
-
let ppt: PuppeteerNode | undefined;
|
|
264
|
-
try {
|
|
265
|
-
const mod = await import("puppeteer" as string);
|
|
266
|
-
ppt = mod.default;
|
|
267
|
-
} catch (err) {
|
|
268
|
-
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
269
|
-
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") {
|
|
270
|
-
throw err;
|
|
271
|
-
}
|
|
272
|
-
const mod = await import("puppeteer-core");
|
|
273
|
-
ppt = mod.default;
|
|
274
|
-
}
|
|
275
|
-
if (!ppt) throw new Error("Neither puppeteer nor puppeteer-core found");
|
|
276
|
-
|
|
277
|
-
const chromePath = resolveHeadedChromePath();
|
|
278
|
-
if (!chromePath) {
|
|
279
|
-
throw new Error(
|
|
280
|
-
"[HDR] No Chrome binary found. Install: npx @puppeteer/browsers install chrome@stable",
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const browser = await ppt.launch({
|
|
285
|
-
headless: false,
|
|
286
|
-
executablePath: chromePath,
|
|
287
|
-
args: buildHdrChromeArgs(width, height),
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
const page = await browser.newPage();
|
|
291
|
-
await page.setViewport({ width, height });
|
|
292
|
-
|
|
293
|
-
return { browser, page };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
export function buildHdrChromeArgs(width: number, height: number): string[] {
|
|
297
|
-
return [
|
|
298
|
-
"--enable-unsafe-webgpu",
|
|
299
|
-
"--no-sandbox",
|
|
300
|
-
"--disable-setuid-sandbox",
|
|
301
|
-
"--window-position=-10000,-10000",
|
|
302
|
-
`--window-size=${width},${height}`,
|
|
303
|
-
"--disable-background-timer-throttling",
|
|
304
|
-
"--disable-backgrounding-occluded-windows",
|
|
305
|
-
"--disable-renderer-backgrounding",
|
|
306
|
-
"--disable-background-media-suspend",
|
|
307
|
-
"--disable-extensions",
|
|
308
|
-
"--disable-component-update",
|
|
309
|
-
"--disable-default-apps",
|
|
310
|
-
"--disable-sync",
|
|
311
|
-
"--no-zygote",
|
|
312
|
-
"--force-gpu-mem-available-mb=4096",
|
|
313
|
-
"--autoplay-policy=no-user-gesture-required",
|
|
314
|
-
];
|
|
315
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
calculateOptimalWorkers,
|
|
4
|
-
distributeFrames,
|
|
5
|
-
formatWorkerFailure,
|
|
6
|
-
selectWorkerDiagnostics,
|
|
7
|
-
shouldVerifyWorkerGpu,
|
|
8
|
-
} from "./parallelCoordinator.js";
|
|
9
|
-
import type { EngineConfig } from "../config.js";
|
|
10
|
-
|
|
11
|
-
describe("distributeFrames", () => {
|
|
12
|
-
it("distributes frames evenly across workers", () => {
|
|
13
|
-
const tasks = distributeFrames(100, 4, "/tmp/work");
|
|
14
|
-
expect(tasks).toHaveLength(4);
|
|
15
|
-
|
|
16
|
-
// First worker: frames 0-24
|
|
17
|
-
expect(tasks[0]?.startFrame).toBe(0);
|
|
18
|
-
expect(tasks[0]?.endFrame).toBe(25);
|
|
19
|
-
|
|
20
|
-
// Last worker: frames 75-99
|
|
21
|
-
expect(tasks[3]?.startFrame).toBe(75);
|
|
22
|
-
expect(tasks[3]?.endFrame).toBe(100);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("handles single worker", () => {
|
|
26
|
-
const tasks = distributeFrames(50, 1, "/tmp/work");
|
|
27
|
-
expect(tasks).toHaveLength(1);
|
|
28
|
-
expect(tasks[0]?.startFrame).toBe(0);
|
|
29
|
-
expect(tasks[0]?.endFrame).toBe(50);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("does not create empty tasks when workers exceed frames", () => {
|
|
33
|
-
const tasks = distributeFrames(3, 10, "/tmp/work");
|
|
34
|
-
// Can't have more tasks than frames
|
|
35
|
-
expect(tasks.length).toBeLessThanOrEqual(3);
|
|
36
|
-
// All frames are covered
|
|
37
|
-
const totalFrames = tasks.reduce((sum, t) => sum + (t.endFrame - t.startFrame), 0);
|
|
38
|
-
expect(totalFrames).toBe(3);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("assigns worker output directories", () => {
|
|
42
|
-
const tasks = distributeFrames(60, 2, "/tmp/my-work");
|
|
43
|
-
expect(tasks[0]?.outputDir).toContain("worker-0");
|
|
44
|
-
expect(tasks[1]?.outputDir).toContain("worker-1");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("assigns sequential worker IDs", () => {
|
|
48
|
-
const tasks = distributeFrames(100, 3, "/tmp/work");
|
|
49
|
-
expect(tasks.map((t) => t.workerId)).toEqual([0, 1, 2]);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("calculateOptimalWorkers", () => {
|
|
54
|
-
it("lets high-cost auto renders fall back to one worker when CPU budget requires it", () => {
|
|
55
|
-
const workers = calculateOptimalWorkers(180, undefined, {
|
|
56
|
-
concurrency: 6,
|
|
57
|
-
coresPerWorker: 100,
|
|
58
|
-
minParallelFrames: 120,
|
|
59
|
-
largeRenderThreshold: 1000,
|
|
60
|
-
captureCostMultiplier: 4,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(workers).toBe(1);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("does not apply capture cost to explicit worker requests", () => {
|
|
67
|
-
const workers = calculateOptimalWorkers(180, 4, {
|
|
68
|
-
concurrency: 6,
|
|
69
|
-
coresPerWorker: 100,
|
|
70
|
-
minParallelFrames: 120,
|
|
71
|
-
largeRenderThreshold: 1000,
|
|
72
|
-
captureCostMultiplier: 4,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
expect(workers).toBe(4);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("worker failure diagnostics", () => {
|
|
80
|
-
it("keeps only actionable worker diagnostics and caps the tail", () => {
|
|
81
|
-
const diagnostics = selectWorkerDiagnostics(
|
|
82
|
-
[
|
|
83
|
-
"[Browser] harmless log",
|
|
84
|
-
"[Browser:WARN] noisy warning",
|
|
85
|
-
"[Browser:REQUESTFAILED] GET https://cdn.example.com/a.mp4 resource=media error=net::ERR_FAILED",
|
|
86
|
-
"[Browser:HTTP404] GET https://cdn.example.com/missing.png resource=image Not Found",
|
|
87
|
-
"[FrameCapture:ERROR] page.goto failed mode=screenshot timeoutMs=60000 elapsedMs=60001 url=http://127.0.0.1:4173/index.html error=timeout",
|
|
88
|
-
],
|
|
89
|
-
2,
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
expect(diagnostics).toEqual([
|
|
93
|
-
"[Browser:HTTP404] GET https://cdn.example.com/missing.png resource=image Not Found",
|
|
94
|
-
"[FrameCapture:ERROR] page.goto failed mode=screenshot timeoutMs=60000 elapsedMs=60001 url=http://127.0.0.1:4173/index.html error=timeout",
|
|
95
|
-
]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("adds compact diagnostics to the worker failure message", () => {
|
|
99
|
-
expect(
|
|
100
|
-
formatWorkerFailure({
|
|
101
|
-
workerId: 1,
|
|
102
|
-
framesCaptured: 0,
|
|
103
|
-
startFrame: 0,
|
|
104
|
-
endFrame: 30,
|
|
105
|
-
durationMs: 60_100,
|
|
106
|
-
error: "Navigation timeout of 60000 ms exceeded",
|
|
107
|
-
diagnostics: ["[FrameCapture:ERROR] page.goto failed\n mode=screenshot timeoutMs=60000"],
|
|
108
|
-
}),
|
|
109
|
-
).toBe(
|
|
110
|
-
"Worker 1: Navigation timeout of 60000 ms exceeded; diagnostics: [FrameCapture:ERROR] page.goto failed mode=screenshot timeoutMs=60000",
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe("shouldVerifyWorkerGpu", () => {
|
|
116
|
-
const softwareConfig: Partial<EngineConfig> = { browserGpuMode: "software" };
|
|
117
|
-
|
|
118
|
-
it("returns true for worker 0 when GPU mode is software", () => {
|
|
119
|
-
expect(shouldVerifyWorkerGpu(0, softwareConfig)).toBe(true);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("returns false for non-zero workers when GPU mode is software", () => {
|
|
123
|
-
expect(shouldVerifyWorkerGpu(1, softwareConfig)).toBe(false);
|
|
124
|
-
expect(shouldVerifyWorkerGpu(5, softwareConfig)).toBe(false);
|
|
125
|
-
expect(shouldVerifyWorkerGpu(17, softwareConfig)).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("returns false for any worker when GPU mode is not software", () => {
|
|
129
|
-
expect(shouldVerifyWorkerGpu(0, { browserGpuMode: "hardware" } as Partial<EngineConfig>)).toBe(
|
|
130
|
-
false,
|
|
131
|
-
);
|
|
132
|
-
expect(shouldVerifyWorkerGpu(0, {})).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("returns false when config is undefined", () => {
|
|
136
|
-
expect(shouldVerifyWorkerGpu(0, undefined)).toBe(false);
|
|
137
|
-
expect(shouldVerifyWorkerGpu(3, undefined)).toBe(false);
|
|
138
|
-
});
|
|
139
|
-
});
|