@hyperframes/engine 0.6.119 → 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,176 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
|
6
|
-
|
|
7
|
-
const SAMPLE_RATE = 48000;
|
|
8
|
-
const CHANNELS = 2;
|
|
9
|
-
|
|
10
|
-
/** Build a PCM s16le stereo WAV whose every sample equals `value`. */
|
|
11
|
-
function writeConstantWav(path: string, frames: number, value: number): void {
|
|
12
|
-
const bytesPerSample = 2;
|
|
13
|
-
const dataSize = frames * CHANNELS * bytesPerSample;
|
|
14
|
-
const buffer = Buffer.alloc(44 + dataSize);
|
|
15
|
-
buffer.write("RIFF", 0, "ascii");
|
|
16
|
-
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
17
|
-
buffer.write("WAVE", 8, "ascii");
|
|
18
|
-
buffer.write("fmt ", 12, "ascii");
|
|
19
|
-
buffer.writeUInt32LE(16, 16);
|
|
20
|
-
buffer.writeUInt16LE(1, 20); // PCM
|
|
21
|
-
buffer.writeUInt16LE(CHANNELS, 22);
|
|
22
|
-
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
|
23
|
-
buffer.writeUInt32LE(SAMPLE_RATE * CHANNELS * bytesPerSample, 28);
|
|
24
|
-
buffer.writeUInt16LE(CHANNELS * bytesPerSample, 32);
|
|
25
|
-
buffer.writeUInt16LE(16, 34);
|
|
26
|
-
buffer.write("data", 36, "ascii");
|
|
27
|
-
buffer.writeUInt32LE(dataSize, 40);
|
|
28
|
-
for (let i = 0; i < frames * CHANNELS; i += 1) buffer.writeInt16LE(value, 44 + i * 2);
|
|
29
|
-
writeFileSync(path, buffer);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function sampleAt(path: string, frame: number, channel = 0): number {
|
|
33
|
-
const buffer = readFileSync(path);
|
|
34
|
-
return buffer.readInt16LE(44 + (frame * CHANNELS + channel) * 2);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe("applyVolumeEnvelopeToWav", () => {
|
|
38
|
-
const dirs: string[] = [];
|
|
39
|
-
const tmp = () => {
|
|
40
|
-
const d = mkdtempSync(join(tmpdir(), "hf-env-"));
|
|
41
|
-
dirs.push(d);
|
|
42
|
-
return d;
|
|
43
|
-
};
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("applies a linear fade sample-accurately", () => {
|
|
49
|
-
const path = join(tmp(), "a.wav");
|
|
50
|
-
const frames = SAMPLE_RATE; // 1 second
|
|
51
|
-
writeConstantWav(path, frames, 10000);
|
|
52
|
-
|
|
53
|
-
// Fade 0 -> 1 over the full second.
|
|
54
|
-
const applied = applyVolumeEnvelopeToWav(
|
|
55
|
-
path,
|
|
56
|
-
[
|
|
57
|
-
{ time: 0, volume: 0 },
|
|
58
|
-
{ time: 1, volume: 1 },
|
|
59
|
-
],
|
|
60
|
-
0,
|
|
61
|
-
0,
|
|
62
|
-
);
|
|
63
|
-
expect(applied).toBe(true);
|
|
64
|
-
|
|
65
|
-
expect(sampleAt(path, 0)).toBe(0); // gain 0
|
|
66
|
-
expect(sampleAt(path, frames / 2)).toBeCloseTo(5000, -2); // gain ~0.5
|
|
67
|
-
expect(sampleAt(path, frames - 1)).toBeGreaterThan(9900); // gain ~1
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("offsets keyframes by the track start (composition time -> track-relative)", () => {
|
|
71
|
-
const path = join(tmp(), "b.wav");
|
|
72
|
-
const frames = SAMPLE_RATE;
|
|
73
|
-
writeConstantWav(path, frames, 10000);
|
|
74
|
-
|
|
75
|
-
// Track starts at 5s; the fade runs from comp-time 5s..6s -> wav 0s..1s.
|
|
76
|
-
applyVolumeEnvelopeToWav(
|
|
77
|
-
path,
|
|
78
|
-
[
|
|
79
|
-
{ time: 5, volume: 0 },
|
|
80
|
-
{ time: 6, volume: 1 },
|
|
81
|
-
],
|
|
82
|
-
5,
|
|
83
|
-
0,
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
expect(sampleAt(path, 0)).toBe(0);
|
|
87
|
-
expect(sampleAt(path, frames / 2)).toBeCloseTo(5000, -2);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("holds base volume before the first keyframe and the last value after", () => {
|
|
91
|
-
const path = join(tmp(), "c.wav");
|
|
92
|
-
const frames = SAMPLE_RATE * 3; // 3 seconds
|
|
93
|
-
writeConstantWav(path, frames, 10000);
|
|
94
|
-
|
|
95
|
-
// Base 0.8 held until a fade-out begins at 2s.
|
|
96
|
-
applyVolumeEnvelopeToWav(
|
|
97
|
-
path,
|
|
98
|
-
[
|
|
99
|
-
{ time: 2, volume: 0.8 },
|
|
100
|
-
{ time: 3, volume: 0 },
|
|
101
|
-
],
|
|
102
|
-
0,
|
|
103
|
-
0.8,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
expect(sampleAt(path, SAMPLE_RATE)).toBeCloseTo(8000, -2); // 1s: base 0.8
|
|
107
|
-
expect(sampleAt(path, frames - 1)).toBeLessThan(200); // 3s: faded to ~0
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("handles thousands of keyframes without failing (no expression ceiling)", () => {
|
|
111
|
-
const path = join(tmp(), "d.wav");
|
|
112
|
-
const frames = SAMPLE_RATE * 2;
|
|
113
|
-
writeConstantWav(path, frames, 10000);
|
|
114
|
-
|
|
115
|
-
const keyframes = Array.from({ length: 5000 }, (_, i) => ({
|
|
116
|
-
time: (i / 4999) * 2,
|
|
117
|
-
volume: Math.abs(Math.sin(i / 50)),
|
|
118
|
-
}));
|
|
119
|
-
expect(applyVolumeEnvelopeToWav(path, keyframes, 0, 0)).toBe(true);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("parses chunks in any order (data before fmt)", () => {
|
|
123
|
-
const path = join(tmp(), "order.wav");
|
|
124
|
-
const frames = 4;
|
|
125
|
-
const dataSize = frames * CHANNELS * 2;
|
|
126
|
-
// Lay the data chunk before fmt to exercise order-independent scanning.
|
|
127
|
-
const buffer = Buffer.alloc(12 + (8 + dataSize) + (8 + 16));
|
|
128
|
-
buffer.write("RIFF", 0, "ascii");
|
|
129
|
-
buffer.writeUInt32LE(buffer.length - 8, 4);
|
|
130
|
-
buffer.write("WAVE", 8, "ascii");
|
|
131
|
-
let o = 12;
|
|
132
|
-
buffer.write("data", o, "ascii");
|
|
133
|
-
buffer.writeUInt32LE(dataSize, o + 4);
|
|
134
|
-
for (let i = 0; i < frames * CHANNELS; i += 1) buffer.writeInt16LE(10000, o + 8 + i * 2);
|
|
135
|
-
o += 8 + dataSize;
|
|
136
|
-
buffer.write("fmt ", o, "ascii");
|
|
137
|
-
buffer.writeUInt32LE(16, o + 4);
|
|
138
|
-
buffer.writeUInt16LE(1, o + 8);
|
|
139
|
-
buffer.writeUInt16LE(CHANNELS, o + 10);
|
|
140
|
-
buffer.writeUInt32LE(SAMPLE_RATE, o + 12);
|
|
141
|
-
buffer.writeUInt16LE(16, o + 22);
|
|
142
|
-
writeFileSync(path, buffer);
|
|
143
|
-
|
|
144
|
-
expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0 }], 0, 0)).toBe(true);
|
|
145
|
-
expect(readFileSync(path).readInt16LE(12 + 8)).toBe(0); // first sample muted
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("rejects non-16-bit PCM so the caller can fall back", () => {
|
|
149
|
-
const path = join(tmp(), "e.wav");
|
|
150
|
-
// 24-bit PCM header (bitsPerSample = 24); body contents are irrelevant.
|
|
151
|
-
const buffer = Buffer.alloc(44);
|
|
152
|
-
buffer.write("RIFF", 0, "ascii");
|
|
153
|
-
buffer.write("WAVE", 8, "ascii");
|
|
154
|
-
buffer.write("fmt ", 12, "ascii");
|
|
155
|
-
buffer.writeUInt32LE(16, 16);
|
|
156
|
-
buffer.writeUInt16LE(1, 20);
|
|
157
|
-
buffer.writeUInt16LE(CHANNELS, 22);
|
|
158
|
-
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
|
159
|
-
buffer.writeUInt16LE(24, 34);
|
|
160
|
-
buffer.write("data", 36, "ascii");
|
|
161
|
-
buffer.writeUInt32LE(0, 40);
|
|
162
|
-
writeFileSync(path, buffer);
|
|
163
|
-
|
|
164
|
-
expect(
|
|
165
|
-
applyVolumeEnvelopeToWav(
|
|
166
|
-
path,
|
|
167
|
-
[
|
|
168
|
-
{ time: 0, volume: 0 },
|
|
169
|
-
{ time: 1, volume: 1 },
|
|
170
|
-
],
|
|
171
|
-
0,
|
|
172
|
-
0,
|
|
173
|
-
),
|
|
174
|
-
).toBe(false);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sample-accurate volume automation.
|
|
3
|
-
*
|
|
4
|
-
* The audio mixer's primary path for time-varying volume bakes the envelope
|
|
5
|
-
* directly into the prepared PCM rather than encoding it as an FFmpeg `volume`
|
|
6
|
-
* expression. The expression approach nests one `if(lt(t,...))` per keyframe and
|
|
7
|
-
* overflows FFmpeg's expression evaluator past ~95 levels (a dense GSAP fade
|
|
8
|
-
* emits hundreds of keyframes), which fails the whole mix and drops the audio
|
|
9
|
-
* track. Multiplying the samples in-house has no such ceiling, is exact at every
|
|
10
|
-
* sample, and keeps the downstream ffmpeg `amix`/AAC encode untouched — so the
|
|
11
|
-
* output (and the golden baselines) only change where a fade is actually applied.
|
|
12
|
-
*
|
|
13
|
-
* The prepared tracks are always `pcm_s16le`, 48 kHz, stereo (see
|
|
14
|
-
* `prepareAudioTrack` / `extractAudioFromVideo`). Anything else is rejected so
|
|
15
|
-
* the caller can fall back to the expression path rather than corrupting audio.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { readFileSync, renameSync, writeFileSync } from "fs";
|
|
19
|
-
import { randomBytes } from "crypto";
|
|
20
|
-
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
|
|
21
|
-
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
|
22
|
-
|
|
23
|
-
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
|
|
24
|
-
const SUPPORTED_BITS = 16;
|
|
25
|
-
|
|
26
|
-
interface WavLayout {
|
|
27
|
-
numChannels: number;
|
|
28
|
-
sampleRate: number;
|
|
29
|
-
dataOffset: number;
|
|
30
|
-
dataSize: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Locate the `fmt ` and `data` chunks and validate the format we know how to edit.
|
|
35
|
-
*
|
|
36
|
-
* Scans every chunk rather than assuming an ordering: the loop always advances
|
|
37
|
-
* past a chunk's body (using its declared size), so `data` may precede `fmt `
|
|
38
|
-
* and trailing chunks (LIST/fact/etc.) are skipped harmlessly. Returns null on
|
|
39
|
-
* anything unexpected so the caller falls back to the expression path.
|
|
40
|
-
*/
|
|
41
|
-
function parseWavLayout(buffer: Buffer): WavLayout | null {
|
|
42
|
-
if (buffer.length < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") return null;
|
|
43
|
-
if (buffer.toString("ascii", 8, 12) !== "WAVE") return null;
|
|
44
|
-
|
|
45
|
-
let offset = 12;
|
|
46
|
-
let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | null = null;
|
|
47
|
-
let data: { offset: number; size: number } | null = null;
|
|
48
|
-
|
|
49
|
-
while (offset + 8 <= buffer.length) {
|
|
50
|
-
const chunkId = buffer.toString("ascii", offset, offset + 4);
|
|
51
|
-
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
52
|
-
const body = offset + 8;
|
|
53
|
-
if (chunkId === "fmt " && body + 16 <= buffer.length) {
|
|
54
|
-
if (buffer.readUInt16LE(body) !== PCM_FORMAT) return null;
|
|
55
|
-
fmt = {
|
|
56
|
-
numChannels: buffer.readUInt16LE(body + 2),
|
|
57
|
-
sampleRate: buffer.readUInt32LE(body + 4),
|
|
58
|
-
bitsPerSample: buffer.readUInt16LE(body + 14),
|
|
59
|
-
};
|
|
60
|
-
} else if (chunkId === "data") {
|
|
61
|
-
data = { offset: body, size: Math.min(chunkSize, buffer.length - body) };
|
|
62
|
-
}
|
|
63
|
-
// Chunks are word-aligned: an odd size carries a trailing pad byte.
|
|
64
|
-
offset = body + chunkSize + (chunkSize % 2);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!fmt || !data) return null;
|
|
68
|
-
if (fmt.bitsPerSample !== SUPPORTED_BITS || fmt.numChannels < 1) return null;
|
|
69
|
-
return {
|
|
70
|
-
numChannels: fmt.numChannels,
|
|
71
|
-
sampleRate: fmt.sampleRate,
|
|
72
|
-
dataOffset: data.offset,
|
|
73
|
-
dataSize: data.size,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Multiply a prepared WAV's samples by a time-varying gain envelope in place.
|
|
79
|
-
*
|
|
80
|
-
* @returns `true` if the envelope was applied; `false` if the file isn't the
|
|
81
|
-
* expected 16-bit PCM (caller should fall back to the expression path).
|
|
82
|
-
*/
|
|
83
|
-
export function applyVolumeEnvelopeToWav(
|
|
84
|
-
wavPath: string,
|
|
85
|
-
keyframes: AudioVolumeKeyframe[],
|
|
86
|
-
trackStart: number,
|
|
87
|
-
baseVolume: number,
|
|
88
|
-
): boolean {
|
|
89
|
-
const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume);
|
|
90
|
-
if (envelope.length === 0) return false;
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const buffer = readFileSync(wavPath);
|
|
94
|
-
const layout = parseWavLayout(buffer);
|
|
95
|
-
if (!layout) return false;
|
|
96
|
-
|
|
97
|
-
const { numChannels, sampleRate, dataOffset, dataSize } = layout;
|
|
98
|
-
const bytesPerSample = SUPPORTED_BITS / 8;
|
|
99
|
-
const frameBytes = numChannels * bytesPerSample;
|
|
100
|
-
const frameCount = Math.floor(dataSize / frameBytes);
|
|
101
|
-
|
|
102
|
-
// Maintain an incremental segment cursor so the per-frame envelope lookup
|
|
103
|
-
// is O(N+M) overall, not O(N×M). interpolateVolumeGain restarts from 0 on
|
|
104
|
-
// each call — fine for the preview path (one call per RAF tick) but not for
|
|
105
|
-
// the PCM path (one call per sample, 48k×duration frames total).
|
|
106
|
-
let segment = 0;
|
|
107
|
-
for (let frame = 0; frame < frameCount; frame += 1) {
|
|
108
|
-
const time = frame / sampleRate;
|
|
109
|
-
while (segment < envelope.length - 2 && time >= envelope[segment + 1]!.time) segment += 1;
|
|
110
|
-
|
|
111
|
-
const a = envelope[segment]!;
|
|
112
|
-
const b = envelope[segment + 1] ?? a;
|
|
113
|
-
const span = b.time - a.time;
|
|
114
|
-
const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (time - a.time) / span));
|
|
115
|
-
const gain = a.volume + (b.volume - a.volume) * progress;
|
|
116
|
-
|
|
117
|
-
const base = dataOffset + frame * frameBytes;
|
|
118
|
-
for (let channel = 0; channel < numChannels; channel += 1) {
|
|
119
|
-
const at = base + channel * bytesPerSample;
|
|
120
|
-
const scaled = Math.round(buffer.readInt16LE(at) * gain);
|
|
121
|
-
buffer.writeInt16LE(scaled < -32768 ? -32768 : scaled > 32767 ? 32767 : scaled, at);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Write to a uniquely-named sibling then atomically rename over the
|
|
126
|
-
// original. The random name avoids following a pre-planted symlink at a
|
|
127
|
-
// predictable path, and the rename means a crash mid-write can't leave a
|
|
128
|
-
// truncated WAV for the downstream mix.
|
|
129
|
-
const tempPath = `${wavPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
130
|
-
writeFileSync(tempPath, buffer);
|
|
131
|
-
renameSync(tempPath, wavPath);
|
|
132
|
-
return true;
|
|
133
|
-
} catch {
|
|
134
|
-
// Any read/parse/write failure → leave the file untouched and let the
|
|
135
|
-
// caller fall back to the ffmpeg expression path rather than losing audio.
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { Browser, PuppeteerNode } from "puppeteer-core";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
_resetAutoBrowserGpuModeCacheForTests,
|
|
7
|
-
_resetBrowserPoolForTests,
|
|
8
|
-
_setPuppeteerForTests,
|
|
9
|
-
acquireBrowser,
|
|
10
|
-
buildChromeArgs,
|
|
11
|
-
drainBrowserPool,
|
|
12
|
-
forceReleaseBrowser,
|
|
13
|
-
releaseBrowser,
|
|
14
|
-
resolveHeadlessShellPath,
|
|
15
|
-
resolveBrowserGpuMode,
|
|
16
|
-
} from "./browserManager.js";
|
|
17
|
-
|
|
18
|
-
describe("buildChromeArgs browser GPU mode", () => {
|
|
19
|
-
const base = { width: 1920, height: 1080 };
|
|
20
|
-
|
|
21
|
-
it("uses SwiftShader software GL by default for reproducible local renders", () => {
|
|
22
|
-
const args = buildChromeArgs(base);
|
|
23
|
-
expect(args).toContain("--enable-features=CanvasDrawElement");
|
|
24
|
-
expect(args).not.toContain("--enable-unsafe-webgpu");
|
|
25
|
-
expect(args).toContain("--use-gl=angle");
|
|
26
|
-
expect(args).toContain("--use-angle=swiftshader");
|
|
27
|
-
expect(args).toContain("--enable-unsafe-swiftshader");
|
|
28
|
-
expect(args).not.toContain("--enable-gpu-rasterization");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("uses Metal-backed ANGLE for hardware browser GPU mode on macOS", () => {
|
|
32
|
-
const args = buildChromeArgs({ ...base, platform: "darwin" }, { browserGpuMode: "hardware" });
|
|
33
|
-
expect(args).toContain("--enable-unsafe-webgpu");
|
|
34
|
-
expect(args).toContain("--use-gl=angle");
|
|
35
|
-
expect(args).toContain("--use-angle=metal");
|
|
36
|
-
expect(args).toContain("--enable-gpu-rasterization");
|
|
37
|
-
expect(args).not.toContain("--use-angle=swiftshader");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("uses D3D11-backed ANGLE for hardware browser GPU mode on Windows", () => {
|
|
41
|
-
const args = buildChromeArgs({ ...base, platform: "win32" }, { browserGpuMode: "hardware" });
|
|
42
|
-
expect(args).toContain("--use-gl=angle");
|
|
43
|
-
expect(args).toContain("--use-angle=d3d11");
|
|
44
|
-
expect(args).toContain("--enable-gpu-rasterization");
|
|
45
|
-
expect(args).not.toContain("--use-angle=swiftshader");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("uses ANGLE-EGL for hardware browser GPU mode on Linux", () => {
|
|
49
|
-
const args = buildChromeArgs({ ...base, platform: "linux" }, { browserGpuMode: "hardware" });
|
|
50
|
-
expect(args).toContain("--use-gl=angle");
|
|
51
|
-
expect(args).toContain("--use-angle=gl-egl");
|
|
52
|
-
expect(args).toContain("--enable-gpu-rasterization");
|
|
53
|
-
expect(args).toContain("--ignore-gpu-blocklist");
|
|
54
|
-
expect(args).toContain("--disable-software-rasterizer");
|
|
55
|
-
expect(args).not.toContain("--use-angle=swiftshader");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("keeps --disable-gpu authoritative when requested", () => {
|
|
59
|
-
const args = buildChromeArgs(
|
|
60
|
-
{ ...base, platform: "darwin" },
|
|
61
|
-
{ browserGpuMode: "hardware", disableGpu: true },
|
|
62
|
-
);
|
|
63
|
-
expect(args).toContain("--disable-gpu");
|
|
64
|
-
expect(args).toContain("--use-angle=swiftshader");
|
|
65
|
-
expect(args).not.toContain("--use-angle=metal");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("resolveBrowserGpuMode", () => {
|
|
70
|
-
beforeEach(() => {
|
|
71
|
-
_resetAutoBrowserGpuModeCacheForTests();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
vi.restoreAllMocks();
|
|
76
|
-
_resetAutoBrowserGpuModeCacheForTests();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("passes 'software' through unchanged without probing", async () => {
|
|
80
|
-
const mode = await resolveBrowserGpuMode("software");
|
|
81
|
-
expect(mode).toBe("software");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("passes 'hardware' through unchanged without probing", async () => {
|
|
85
|
-
const mode = await resolveBrowserGpuMode("hardware");
|
|
86
|
-
expect(mode).toBe("hardware");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("falls back to 'software' when the probe browser cannot launch", async () => {
|
|
90
|
-
// No chromePath, env unset, and (in the test env) no system Chrome to find
|
|
91
|
-
// → puppeteer.launch will throw → caller catches → software fallback.
|
|
92
|
-
// Force a definitely-missing chrome binary so the launch path errors fast.
|
|
93
|
-
const mode = await resolveBrowserGpuMode("auto", {
|
|
94
|
-
chromePath: "/definitely/not/a/real/chrome/binary",
|
|
95
|
-
browserTimeout: 2000,
|
|
96
|
-
});
|
|
97
|
-
expect(mode).toBe("software");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("caches the probe result across calls", async () => {
|
|
101
|
-
const first = await resolveBrowserGpuMode("auto", {
|
|
102
|
-
chromePath: "/definitely/not/a/real/chrome/binary",
|
|
103
|
-
browserTimeout: 2000,
|
|
104
|
-
});
|
|
105
|
-
// Second call uses cache — no new launch. Assert the same answer comes back
|
|
106
|
-
// even with a different chromePath that would have a different probe outcome.
|
|
107
|
-
const second = await resolveBrowserGpuMode("auto", {
|
|
108
|
-
chromePath: "/another/definitely/missing/path",
|
|
109
|
-
browserTimeout: 2000,
|
|
110
|
-
});
|
|
111
|
-
expect(first).toBe("software");
|
|
112
|
-
expect(second).toBe("software");
|
|
113
|
-
// Reset and re-probe to confirm the test-only reset works.
|
|
114
|
-
_resetAutoBrowserGpuModeCacheForTests();
|
|
115
|
-
const third = await resolveBrowserGpuMode("hardware");
|
|
116
|
-
expect(third).toBe("hardware");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => {
|
|
120
|
-
// Parallel coordinator fires N workers via Promise.all — without Promise-
|
|
121
|
-
// level caching, a `--workers 4` render against a no-GPU host would launch
|
|
122
|
-
// 4 simultaneous probe Chromes. Verify all concurrent callers get the
|
|
123
|
-
// exact same Promise reference (proving the probe runs once, not N times).
|
|
124
|
-
const p1 = resolveBrowserGpuMode("auto", {
|
|
125
|
-
chromePath: "/definitely/not/a/real/chrome/binary",
|
|
126
|
-
browserTimeout: 2000,
|
|
127
|
-
});
|
|
128
|
-
const p2 = resolveBrowserGpuMode("auto", {
|
|
129
|
-
chromePath: "/definitely/not/a/real/chrome/binary",
|
|
130
|
-
browserTimeout: 2000,
|
|
131
|
-
});
|
|
132
|
-
const p3 = resolveBrowserGpuMode("auto", {
|
|
133
|
-
chromePath: "/definitely/not/a/real/chrome/binary",
|
|
134
|
-
browserTimeout: 2000,
|
|
135
|
-
});
|
|
136
|
-
expect(p1).toBe(p2);
|
|
137
|
-
expect(p2).toBe(p3);
|
|
138
|
-
const results = await Promise.all([p1, p2, p3]);
|
|
139
|
-
expect(results).toEqual(["software", "software", "software"]);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("resolveHeadlessShellPath", () => {
|
|
144
|
-
const originalHeadlessShellPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
|
145
|
-
|
|
146
|
-
afterEach(() => {
|
|
147
|
-
if (originalHeadlessShellPath === undefined) delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
|
|
148
|
-
else process.env.PRODUCER_HEADLESS_SHELL_PATH = originalHeadlessShellPath;
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("throws a clear error when PRODUCER_HEADLESS_SHELL_PATH points at a missing binary", () => {
|
|
152
|
-
process.env.PRODUCER_HEADLESS_SHELL_PATH = "/missing/chrome-headless-shell.exe";
|
|
153
|
-
|
|
154
|
-
expect(() => resolveHeadlessShellPath({})).toThrow(
|
|
155
|
-
/Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH/,
|
|
156
|
-
);
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
describe("forceReleaseBrowser", () => {
|
|
161
|
-
it("kills the browser process and disconnects", () => {
|
|
162
|
-
const killFn = vi.fn(() => true);
|
|
163
|
-
const disconnectFn = vi.fn();
|
|
164
|
-
const mockBrowser = {
|
|
165
|
-
process: () => ({ kill: killFn, killed: false }),
|
|
166
|
-
disconnect: disconnectFn,
|
|
167
|
-
} as any;
|
|
168
|
-
|
|
169
|
-
forceReleaseBrowser(mockBrowser);
|
|
170
|
-
|
|
171
|
-
expect(killFn).toHaveBeenCalledWith("SIGKILL");
|
|
172
|
-
expect(disconnectFn).toHaveBeenCalled();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("tolerates an already-killed process", () => {
|
|
176
|
-
const killFn = vi.fn();
|
|
177
|
-
const disconnectFn = vi.fn();
|
|
178
|
-
const mockBrowser = {
|
|
179
|
-
process: () => ({ kill: killFn, killed: true }),
|
|
180
|
-
disconnect: disconnectFn,
|
|
181
|
-
} as any;
|
|
182
|
-
|
|
183
|
-
forceReleaseBrowser(mockBrowser);
|
|
184
|
-
|
|
185
|
-
expect(killFn).not.toHaveBeenCalled();
|
|
186
|
-
expect(disconnectFn).toHaveBeenCalled();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe("browser pool", () => {
|
|
191
|
-
function makeMockBrowser(): Browser {
|
|
192
|
-
return {
|
|
193
|
-
connected: true,
|
|
194
|
-
newPage: vi.fn(),
|
|
195
|
-
version: vi.fn().mockResolvedValue("HeadlessChrome/131.0.0.0"),
|
|
196
|
-
close: vi.fn().mockResolvedValue(undefined),
|
|
197
|
-
disconnect: vi.fn(),
|
|
198
|
-
process: () => ({ kill: vi.fn(), killed: false }),
|
|
199
|
-
} as unknown as Browser;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// forceScreenshot: true bypasses the BeginFrame probe path, which on Linux
|
|
203
|
-
// CI would trigger a second ppt.launch() when the mock's newPage() doesn't
|
|
204
|
-
// return a real page and the probe falls back to screenshot mode.
|
|
205
|
-
const poolCfg = { enableBrowserPool: true, forceScreenshot: true } as const;
|
|
206
|
-
|
|
207
|
-
let launchFn: ReturnType<typeof vi.fn>;
|
|
208
|
-
|
|
209
|
-
beforeEach(() => {
|
|
210
|
-
_resetBrowserPoolForTests();
|
|
211
|
-
const mockBrowser = makeMockBrowser();
|
|
212
|
-
launchFn = vi.fn().mockResolvedValue(mockBrowser);
|
|
213
|
-
_setPuppeteerForTests({ launch: launchFn } as unknown as PuppeteerNode);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
afterEach(async () => {
|
|
217
|
-
await drainBrowserPool();
|
|
218
|
-
_setPuppeteerForTests(undefined);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("sequential acquires with pool enabled return the same browser", async () => {
|
|
222
|
-
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
223
|
-
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
224
|
-
|
|
225
|
-
expect(first.browser).toBe(second.browser);
|
|
226
|
-
expect(launchFn).toHaveBeenCalledTimes(1);
|
|
227
|
-
|
|
228
|
-
await releaseBrowser(first.browser, poolCfg);
|
|
229
|
-
await releaseBrowser(second.browser, poolCfg);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("concurrent acquires via Promise.all trigger exactly one launch", async () => {
|
|
233
|
-
const [a, b, c] = await Promise.all([
|
|
234
|
-
acquireBrowser(["--no-sandbox"], poolCfg),
|
|
235
|
-
acquireBrowser(["--no-sandbox"], poolCfg),
|
|
236
|
-
acquireBrowser(["--no-sandbox"], poolCfg),
|
|
237
|
-
]);
|
|
238
|
-
|
|
239
|
-
expect(launchFn).toHaveBeenCalledTimes(1);
|
|
240
|
-
expect(a.browser).toBe(b.browser);
|
|
241
|
-
expect(b.browser).toBe(c.browser);
|
|
242
|
-
|
|
243
|
-
await releaseBrowser(a.browser, poolCfg);
|
|
244
|
-
await releaseBrowser(b.browser, poolCfg);
|
|
245
|
-
await releaseBrowser(c.browser, poolCfg);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("pool recovers from a disconnected browser", async () => {
|
|
249
|
-
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
250
|
-
await releaseBrowser(first.browser, poolCfg);
|
|
251
|
-
|
|
252
|
-
// Simulate Chrome crash
|
|
253
|
-
(first.browser as unknown as { connected: boolean }).connected = false;
|
|
254
|
-
|
|
255
|
-
const freshBrowser = makeMockBrowser();
|
|
256
|
-
launchFn.mockResolvedValue(freshBrowser);
|
|
257
|
-
|
|
258
|
-
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
259
|
-
expect(second.browser).toBe(freshBrowser);
|
|
260
|
-
expect(second.browser).not.toBe(first.browser);
|
|
261
|
-
expect(launchFn).toHaveBeenCalledTimes(2);
|
|
262
|
-
|
|
263
|
-
await releaseBrowser(second.browser, poolCfg);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("release at refCount 0 closes the browser", async () => {
|
|
267
|
-
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
268
|
-
const closeFn = result.browser.close as ReturnType<typeof vi.fn>;
|
|
269
|
-
|
|
270
|
-
await releaseBrowser(result.browser, poolCfg);
|
|
271
|
-
expect(closeFn).toHaveBeenCalledTimes(1);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("pool returns a separate browser when forceScreenshot mismatches pooled mode", async () => {
|
|
275
|
-
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
276
|
-
expect(first.captureMode).toBe("screenshot");
|
|
277
|
-
|
|
278
|
-
// Second acquire with same forceScreenshot — same mode, should reuse
|
|
279
|
-
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
280
|
-
expect(second.browser).toBe(first.browser);
|
|
281
|
-
expect(launchFn).toHaveBeenCalledTimes(1);
|
|
282
|
-
|
|
283
|
-
await releaseBrowser(first.browser, poolCfg);
|
|
284
|
-
await releaseBrowser(second.browser, poolCfg);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("forceReleaseBrowser does not kill Chrome when other sessions hold refs", async () => {
|
|
288
|
-
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
289
|
-
// Acquire a second ref
|
|
290
|
-
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
|
|
291
|
-
|
|
292
|
-
const disconnectFn = result.browser.disconnect as ReturnType<typeof vi.fn>;
|
|
293
|
-
forceReleaseBrowser(result.browser);
|
|
294
|
-
|
|
295
|
-
// Should NOT have disconnected — other session still holds a ref
|
|
296
|
-
expect(disconnectFn).not.toHaveBeenCalled();
|
|
297
|
-
|
|
298
|
-
// Release the remaining ref normally
|
|
299
|
-
await releaseBrowser(second.browser, poolCfg);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it("drainBrowserPool is safe to call when no browser is pooled", async () => {
|
|
303
|
-
await drainBrowserPool();
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("drainBrowserPool awaits in-flight launch before closing", async () => {
|
|
307
|
-
let resolveDeferred!: (browser: Browser) => void;
|
|
308
|
-
const deferred = new Promise<Browser>((resolve) => {
|
|
309
|
-
resolveDeferred = resolve;
|
|
310
|
-
});
|
|
311
|
-
launchFn.mockReturnValue(deferred);
|
|
312
|
-
|
|
313
|
-
// Start acquire — it will be pending
|
|
314
|
-
const acquirePromise = acquireBrowser(["--no-sandbox"], poolCfg);
|
|
315
|
-
|
|
316
|
-
// Drain while launch is in-flight
|
|
317
|
-
const drainPromise = drainBrowserPool();
|
|
318
|
-
|
|
319
|
-
// Resolve the pending launch
|
|
320
|
-
const mockBrowser = makeMockBrowser();
|
|
321
|
-
resolveDeferred(mockBrowser);
|
|
322
|
-
|
|
323
|
-
await drainPromise;
|
|
324
|
-
const closeFn = mockBrowser.close as ReturnType<typeof vi.fn>;
|
|
325
|
-
expect(closeFn).toHaveBeenCalled();
|
|
326
|
-
|
|
327
|
-
// The acquire should still resolve (the launch completed before drain closed it)
|
|
328
|
-
await acquirePromise.catch(() => {});
|
|
329
|
-
});
|
|
330
|
-
});
|