@hyperframes/engine 0.5.5 → 0.6.0-alpha.1
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/dist/config.d.ts +3 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -3
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/services/audioMixer.d.ts.map +1 -1
- package/dist/services/audioMixer.js +6 -4
- package/dist/services/audioMixer.js.map +1 -1
- package/dist/services/browserManager.d.ts +0 -35
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +1 -113
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +0 -31
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +12 -26
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +0 -7
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +0 -20
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts +0 -20
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +7 -95
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts +1 -40
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +9 -64
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/utils/alphaBlit.d.ts +1 -1
- package/dist/utils/alphaBlit.d.ts.map +1 -1
- package/dist/utils/alphaBlit.js +6 -15
- package/dist/utils/alphaBlit.js.map +1 -1
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +1 -17
- package/dist/utils/ffprobe.js.map +1 -1
- package/package.json +2 -2
- package/src/config.test.ts +0 -7
- package/src/config.ts +4 -31
- package/src/index.ts +0 -2
- package/src/services/audioMixer.ts +6 -4
- package/src/services/browserManager.test.ts +2 -83
- package/src/services/browserManager.ts +1 -130
- package/src/services/chunkEncoder.ts +0 -36
- package/src/services/frameCapture.ts +11 -26
- package/src/services/screenshotService.ts +0 -8
- package/src/services/streamingEncoder.ts +0 -25
- package/src/services/videoFrameExtractor.test.ts +1 -117
- package/src/services/videoFrameExtractor.ts +7 -100
- package/src/services/videoFrameInjector.ts +11 -89
- package/src/utils/alphaBlit.test.ts +0 -10
- package/src/utils/alphaBlit.ts +7 -15
- package/src/utils/ffprobe.test.ts +0 -40
- package/src/utils/ffprobe.ts +1 -16
- package/src/services/screenshotService.test.ts +0 -92
- package/src/services/videoFrameInjector.test.ts +0 -145
|
@@ -15,91 +15,28 @@ import { type BeforeCaptureHook } from "./frameCapture.js";
|
|
|
15
15
|
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
16
16
|
|
|
17
17
|
export interface VideoFrameInjectorOptions extends Partial<
|
|
18
|
-
Pick<EngineConfig, "frameDataUriCacheLimit"
|
|
18
|
+
Pick<EngineConfig, "frameDataUriCacheLimit">
|
|
19
19
|
> {
|
|
20
20
|
frameSrcResolver?: (framePath: string) => string | null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
interface FrameSourceCacheStats {
|
|
24
|
-
entries: number;
|
|
25
|
-
bytes: number;
|
|
26
|
-
/** Total entries evicted since cache creation. A high count vs a small
|
|
27
|
-
* composition signals the byte budget is too tight (cache thrash). */
|
|
28
|
-
evictions: number;
|
|
29
|
-
/** Total inserts rejected because the entry alone exceeds bytesLimit.
|
|
30
|
-
* Non-zero means a single frame is bigger than the configured budget —
|
|
31
|
-
* raise `frameDataUriCacheBytesLimitMb` if it recurs in production. */
|
|
32
|
-
oversizedRejections: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface FrameSourceCache {
|
|
36
|
-
get: (framePath: string) => Promise<string>;
|
|
37
|
-
/** Exposed for tests + telemetry; reflects current cache occupancy. */
|
|
38
|
-
stats: () => FrameSourceCacheStats;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Two-bound LRU keyed by frame path. Either bound triggers eviction of the
|
|
43
|
-
* oldest entry — entry count protects against pathological many-tiny-frames
|
|
44
|
-
* cases, and the byte budget keeps memory bounded when the per-frame data
|
|
45
|
-
* URI grows (4K PNG frames are ~33 MB once base64-encoded).
|
|
46
|
-
*
|
|
47
|
-
* If a single entry's data URI exceeds `bytesLimit`, we skip caching it
|
|
48
|
-
* (returning the URI directly to the caller). Without this guard, the
|
|
49
|
-
* post-insert eviction loop would drop the entry we just inserted and the
|
|
50
|
-
* cache would degrade into a CPU hot path — every subsequent `get()` would
|
|
51
|
-
* re-read from disk and re-base64 the same frame.
|
|
52
|
-
*
|
|
53
|
-
* **Invariant**: cached values MUST be strings whose `.length` equals the
|
|
54
|
-
* byte count we account for at insertion. We derive size on demand via
|
|
55
|
-
* `cache.get(key)?.length` rather than maintaining a parallel `Map<string, number>`.
|
|
56
|
-
* If you ever wrap the value (e.g. cache a Buffer or an object), the byte
|
|
57
|
-
* accounting silently breaks — switch to a parallel size map first.
|
|
58
|
-
*/
|
|
59
23
|
function createFrameSourceCache(
|
|
60
|
-
|
|
61
|
-
bytesLimit: number,
|
|
24
|
+
cacheLimit: number,
|
|
62
25
|
frameSrcResolver?: (framePath: string) => string | null,
|
|
63
|
-
)
|
|
26
|
+
) {
|
|
64
27
|
const cache = new Map<string, string>();
|
|
65
28
|
const inFlight = new Map<string, Promise<string>>();
|
|
66
|
-
let totalBytes = 0;
|
|
67
|
-
let evictions = 0;
|
|
68
|
-
let oversizedRejections = 0;
|
|
69
|
-
|
|
70
|
-
function evictOldest(): void {
|
|
71
|
-
const oldestKey = cache.keys().next().value;
|
|
72
|
-
if (!oldestKey) return;
|
|
73
|
-
// Snapshot the value before deleting so the byte-size derivation can't
|
|
74
|
-
// accidentally read post-delete (a future reorder would silently lose
|
|
75
|
-
// accounting and surface as `totalBytes` drifting out of sync).
|
|
76
|
-
const dropped = cache.get(oldestKey);
|
|
77
|
-
cache.delete(oldestKey);
|
|
78
|
-
totalBytes = Math.max(0, totalBytes - (dropped?.length ?? 0));
|
|
79
|
-
evictions++;
|
|
80
|
-
}
|
|
81
29
|
|
|
82
30
|
function remember(framePath: string, dataUri: string): string {
|
|
83
|
-
// Skip caching entries that alone exceed the byte budget. Caching them
|
|
84
|
-
// would trigger immediate self-eviction on insert and pollute LRU order
|
|
85
|
-
// by displacing the previous entry's slot.
|
|
86
|
-
if (dataUri.length > bytesLimit) {
|
|
87
|
-
oversizedRejections++;
|
|
88
|
-
// Drop any stale prior version so the caller sees consistent state.
|
|
89
|
-
if (cache.has(framePath)) {
|
|
90
|
-
totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
|
|
91
|
-
cache.delete(framePath);
|
|
92
|
-
}
|
|
93
|
-
return dataUri;
|
|
94
|
-
}
|
|
95
31
|
if (cache.has(framePath)) {
|
|
96
|
-
totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
|
|
97
32
|
cache.delete(framePath);
|
|
98
33
|
}
|
|
99
34
|
cache.set(framePath, dataUri);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
35
|
+
if (cache.size > cacheLimit) {
|
|
36
|
+
const oldestKey = cache.keys().next().value;
|
|
37
|
+
if (oldestKey) {
|
|
38
|
+
cache.delete(oldestKey);
|
|
39
|
+
}
|
|
103
40
|
}
|
|
104
41
|
return dataUri;
|
|
105
42
|
}
|
|
@@ -133,19 +70,9 @@ function createFrameSourceCache(
|
|
|
133
70
|
return pending;
|
|
134
71
|
}
|
|
135
72
|
|
|
136
|
-
return {
|
|
137
|
-
get,
|
|
138
|
-
stats: () => ({
|
|
139
|
-
entries: cache.size,
|
|
140
|
-
bytes: totalBytes,
|
|
141
|
-
evictions,
|
|
142
|
-
oversizedRejections,
|
|
143
|
-
}),
|
|
144
|
-
};
|
|
73
|
+
return { get };
|
|
145
74
|
}
|
|
146
75
|
|
|
147
|
-
export const __testing = { createFrameSourceCache };
|
|
148
|
-
|
|
149
76
|
/**
|
|
150
77
|
* Creates a BeforeCaptureHook that injects pre-extracted video frames
|
|
151
78
|
* into the page, replacing native <video> elements with frame images.
|
|
@@ -156,16 +83,11 @@ export function createVideoFrameInjector(
|
|
|
156
83
|
): BeforeCaptureHook | null {
|
|
157
84
|
if (!frameLookup) return null;
|
|
158
85
|
|
|
159
|
-
const
|
|
86
|
+
const cacheLimit = Math.max(
|
|
160
87
|
32,
|
|
161
88
|
config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
|
|
162
89
|
);
|
|
163
|
-
const
|
|
164
|
-
64,
|
|
165
|
-
config?.frameDataUriCacheBytesLimitMb ?? DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb,
|
|
166
|
-
);
|
|
167
|
-
const bytesLimit = bytesLimitMb * 1024 * 1024;
|
|
168
|
-
const frameCache = createFrameSourceCache(entryLimit, bytesLimit, config?.frameSrcResolver);
|
|
90
|
+
const frameCache = createFrameSourceCache(cacheLimit, config?.frameSrcResolver);
|
|
169
91
|
const lastInjectedFrameByVideo = new Map<string, number>();
|
|
170
92
|
|
|
171
93
|
return async (page: Page, time: number) => {
|
|
@@ -511,16 +511,6 @@ describe("blitRgba8OverRgb48le", () => {
|
|
|
511
511
|
expect(canvas.readUInt16LE(4)).toBe(0);
|
|
512
512
|
});
|
|
513
513
|
|
|
514
|
-
it("fully opaque DOM with srgb transfer expands 8-bit channels to 16-bit SDR", () => {
|
|
515
|
-
const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
|
|
516
|
-
const dom = makeDomRgba(1, 1, 255, 128, 1, 255);
|
|
517
|
-
blitRgba8OverRgb48le(dom, canvas, 1, 1, "srgb");
|
|
518
|
-
|
|
519
|
-
expect(canvas.readUInt16LE(0)).toBe(65535);
|
|
520
|
-
expect(canvas.readUInt16LE(2)).toBe(128 * 257);
|
|
521
|
-
expect(canvas.readUInt16LE(4)).toBe(257);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
514
|
it("sRGB→HLG: black stays black, white stays white", () => {
|
|
525
515
|
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
|
526
516
|
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
package/src/utils/alphaBlit.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function decodePngToRgb48le(buf: Buffer): { width: number; height: number
|
|
|
249
249
|
* bt2020). For neutral/near-neutral content (text, UI) the gamut difference
|
|
250
250
|
* is negligible.
|
|
251
251
|
*/
|
|
252
|
-
function
|
|
252
|
+
function buildSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
|
253
253
|
const lut = new Uint16Array(256);
|
|
254
254
|
|
|
255
255
|
// HLG OETF constants (Rec. 2100)
|
|
@@ -267,11 +267,6 @@ function buildSrgbToSignalLut(transfer: "hlg" | "pq" | "srgb"): Uint16Array {
|
|
|
267
267
|
const sdrNits = 203.0;
|
|
268
268
|
|
|
269
269
|
for (let i = 0; i < 256; i++) {
|
|
270
|
-
if (transfer === "srgb") {
|
|
271
|
-
lut[i] = i * 257;
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
270
|
// sRGB EOTF: signal → linear (range 0–1, relative to SDR white)
|
|
276
271
|
const v = i / 255;
|
|
277
272
|
const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
@@ -293,15 +288,12 @@ function buildSrgbToSignalLut(transfer: "hlg" | "pq" | "srgb"): Uint16Array {
|
|
|
293
288
|
return lut;
|
|
294
289
|
}
|
|
295
290
|
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
const SRGB_TO_PQ = buildSrgbToSignalLut("pq");
|
|
291
|
+
const SRGB_TO_HLG = buildSrgbToHdrLut("hlg");
|
|
292
|
+
const SRGB_TO_PQ = buildSrgbToHdrLut("pq");
|
|
299
293
|
|
|
300
294
|
/** Select the correct sRGB→HDR LUT for the given transfer function. */
|
|
301
|
-
function
|
|
302
|
-
|
|
303
|
-
if (transfer === "hlg") return SRGB_TO_HLG;
|
|
304
|
-
return SRGB_TO_SRGB_16;
|
|
295
|
+
function getSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
|
296
|
+
return transfer === "pq" ? SRGB_TO_PQ : SRGB_TO_HLG;
|
|
305
297
|
}
|
|
306
298
|
|
|
307
299
|
// ── Alpha compositing ─────────────────────────────────────────────────────────
|
|
@@ -325,10 +317,10 @@ export function blitRgba8OverRgb48le(
|
|
|
325
317
|
canvas: Buffer,
|
|
326
318
|
width: number,
|
|
327
319
|
height: number,
|
|
328
|
-
transfer: "hlg" | "pq"
|
|
320
|
+
transfer: "hlg" | "pq" = "hlg",
|
|
329
321
|
): void {
|
|
330
322
|
const pixelCount = width * height;
|
|
331
|
-
const lut =
|
|
323
|
+
const lut = getSrgbToHdrLut(transfer);
|
|
332
324
|
|
|
333
325
|
for (let i = 0; i < pixelCount; i++) {
|
|
334
326
|
const da = domRgba[i * 4 + 3] ?? 0;
|
|
@@ -225,46 +225,6 @@ describe("ffprobe missing-binary fallback", () => {
|
|
|
225
225
|
expect(meta.hasAlpha).toBe(true);
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
-
// Regression: newer libavformat builds (and the output of `hyperframes
|
|
229
|
-
// remove-background` itself) write the VP9-alpha sidecar tag as
|
|
230
|
-
// `ALPHA_MODE` (uppercase). The lowercase-only check classified those
|
|
231
|
-
// files as having no alpha, the producer extracted them as JPGs, and
|
|
232
|
-
// the injected <img> overlays were fully opaque rectangles that hid
|
|
233
|
-
// every static element below them on the z-stack. The bug was silent —
|
|
234
|
-
// studio preview rendered correctly via native <video> playback while
|
|
235
|
-
// production renders covered headlines and captions with the avatar.
|
|
236
|
-
it("extractMediaMetadata detects ALPHA_MODE (uppercase) streams from newer ffmpeg builds", async () => {
|
|
237
|
-
const { spawn } = createSpawnSpy([
|
|
238
|
-
{
|
|
239
|
-
kind: "exit",
|
|
240
|
-
code: 0,
|
|
241
|
-
stdout: JSON.stringify({
|
|
242
|
-
streams: [
|
|
243
|
-
{
|
|
244
|
-
codec_type: "video",
|
|
245
|
-
codec_name: "vp9",
|
|
246
|
-
width: 320,
|
|
247
|
-
height: 180,
|
|
248
|
-
r_frame_rate: "30/1",
|
|
249
|
-
avg_frame_rate: "30/1",
|
|
250
|
-
pix_fmt: "yuv420p",
|
|
251
|
-
tags: { ALPHA_MODE: "1" },
|
|
252
|
-
},
|
|
253
|
-
],
|
|
254
|
-
format: { duration: "1.5" },
|
|
255
|
-
}),
|
|
256
|
-
},
|
|
257
|
-
]);
|
|
258
|
-
vi.resetModules();
|
|
259
|
-
vi.doMock("child_process", () => ({ spawn }));
|
|
260
|
-
|
|
261
|
-
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
|
|
262
|
-
const meta = await extractMediaMetadataMocked("/tmp/alpha-uppercase.webm");
|
|
263
|
-
|
|
264
|
-
expect(meta.videoCodec).toBe("vp9");
|
|
265
|
-
expect(meta.hasAlpha).toBe(true);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
228
|
it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => {
|
|
269
229
|
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
|
|
270
230
|
vi.resetModules();
|
package/src/utils/ffprobe.ts
CHANGED
|
@@ -203,21 +203,6 @@ function extractStillImageMetadata(filePath: string): StillImageMetadata | null
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
/**
|
|
207
|
-
* Read an ffprobe tag case-insensitively. ffmpeg/libavformat versions disagree
|
|
208
|
-
* on tag casing — VP9 alpha is `alpha_mode` in older builds and `ALPHA_MODE`
|
|
209
|
-
* in newer ones; HDR tags vary similarly. Use this for any sidecar tag where
|
|
210
|
-
* you want to be resilient across muxer versions.
|
|
211
|
-
*/
|
|
212
|
-
function readTagCI(tags: Record<string, string | undefined> | undefined, name: string): string {
|
|
213
|
-
if (!tags) return "";
|
|
214
|
-
const target = name.toLowerCase();
|
|
215
|
-
for (const [key, value] of Object.entries(tags)) {
|
|
216
|
-
if (key.toLowerCase() === target && typeof value === "string") return value;
|
|
217
|
-
}
|
|
218
|
-
return "";
|
|
219
|
-
}
|
|
220
|
-
|
|
221
206
|
function parseFrameRate(frameRateStr: string | undefined): number {
|
|
222
207
|
if (!frameRateStr) return 0;
|
|
223
208
|
const parts = frameRateStr.split("/");
|
|
@@ -292,7 +277,7 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
|
|
|
292
277
|
: null;
|
|
293
278
|
const colorSpace = ffprobeColorSpace ?? stillImageMeta?.colorSpace ?? null;
|
|
294
279
|
const pixelFormat = videoStream.pix_fmt || "";
|
|
295
|
-
const alphaMode =
|
|
280
|
+
const alphaMode = videoStream.tags?.alpha_mode || "";
|
|
296
281
|
const hasAlpha =
|
|
297
282
|
/(^|[^a-z])yuva|rgba|argb|bgra|gbrap|gray[a-z0-9]*a/i.test(pixelFormat) || alphaMode === "1";
|
|
298
283
|
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
-
import { type Page } from "puppeteer-core";
|
|
4
|
-
import { pageScreenshotCapture, cdpSessionCache } from "./screenshotService.js";
|
|
5
|
-
|
|
6
|
-
// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
|
|
7
|
-
// `client.send("Page.captureScreenshot", ...)` and we can inspect the args.
|
|
8
|
-
function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{ data: string }>) {
|
|
9
|
-
const fakeSession = { send } as unknown as import("puppeteer-core").CDPSession;
|
|
10
|
-
// Stub a Page object — the WeakMap cache is the only Page-thing used in the
|
|
11
|
-
// path under test, so we can pre-seed it and skip page.createCDPSession().
|
|
12
|
-
const fakePage = {} as Page;
|
|
13
|
-
cdpSessionCache.set(fakePage, fakeSession);
|
|
14
|
-
return fakePage;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe("pageScreenshotCapture supersample plumbing", () => {
|
|
18
|
-
// Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
|
|
19
|
-
// and we never inspect the bytes — only the params we pass to client.send.
|
|
20
|
-
const ONE_PIXEL_PNG_B64 =
|
|
21
|
-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
|
|
22
|
-
|
|
23
|
-
it("omits `clip` when deviceScaleFactor is undefined (default 1)", async () => {
|
|
24
|
-
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
|
25
|
-
const page = makeFakePageWithCdp(send);
|
|
26
|
-
|
|
27
|
-
await pageScreenshotCapture(page, {
|
|
28
|
-
width: 1920,
|
|
29
|
-
height: 1080,
|
|
30
|
-
fps: 30,
|
|
31
|
-
format: "jpeg",
|
|
32
|
-
quality: 80,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
expect(send).toHaveBeenCalledWith(
|
|
36
|
-
"Page.captureScreenshot",
|
|
37
|
-
expect.not.objectContaining({ clip: expect.anything() }),
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("omits `clip` when deviceScaleFactor is exactly 1", async () => {
|
|
42
|
-
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
|
43
|
-
const page = makeFakePageWithCdp(send);
|
|
44
|
-
|
|
45
|
-
await pageScreenshotCapture(page, {
|
|
46
|
-
width: 1920,
|
|
47
|
-
height: 1080,
|
|
48
|
-
fps: 30,
|
|
49
|
-
format: "jpeg",
|
|
50
|
-
deviceScaleFactor: 1,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const params = send.mock.calls[0]?.[1] as { clip?: unknown };
|
|
54
|
-
expect(params.clip).toBeUndefined();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
|
|
58
|
-
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
|
59
|
-
const page = makeFakePageWithCdp(send);
|
|
60
|
-
|
|
61
|
-
await pageScreenshotCapture(page, {
|
|
62
|
-
width: 1920,
|
|
63
|
-
height: 1080,
|
|
64
|
-
fps: 30,
|
|
65
|
-
format: "jpeg",
|
|
66
|
-
deviceScaleFactor: 2,
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
expect(send).toHaveBeenCalledWith(
|
|
70
|
-
"Page.captureScreenshot",
|
|
71
|
-
expect.objectContaining({
|
|
72
|
-
clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 2 },
|
|
73
|
-
}),
|
|
74
|
-
);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
|
|
78
|
-
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
|
|
79
|
-
const page = makeFakePageWithCdp(send);
|
|
80
|
-
|
|
81
|
-
await pageScreenshotCapture(page, {
|
|
82
|
-
width: 1280,
|
|
83
|
-
height: 720,
|
|
84
|
-
fps: 30,
|
|
85
|
-
format: "jpeg",
|
|
86
|
-
deviceScaleFactor: 3,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
|
|
90
|
-
expect(params.clip?.scale).toBe(3);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { __testing } from "./videoFrameInjector.js";
|
|
7
|
-
import { DEFAULT_CONFIG } from "../config.js";
|
|
8
|
-
|
|
9
|
-
const { createFrameSourceCache } = __testing;
|
|
10
|
-
|
|
11
|
-
const SHARED_STATS = { evictions: 0, oversizedRejections: 0 };
|
|
12
|
-
|
|
13
|
-
describe("frame source cache eviction", () => {
|
|
14
|
-
let dir: string;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
dir = mkdtempSync(join(tmpdir(), "hf-frame-cache-test-"));
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
rmSync(dir, { recursive: true, force: true });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
// Each PNG is base64-encoded into the data URI, so the cached string is
|
|
25
|
-
// ~4/3 the file size plus a small `data:image/png;base64,` prefix. Build
|
|
26
|
-
// distinct files so eviction has predictable victims.
|
|
27
|
-
function writeFrame(name: string, sizeBytes: number): string {
|
|
28
|
-
const filePath = join(dir, name);
|
|
29
|
-
writeFileSync(filePath, Buffer.alloc(sizeBytes, 0));
|
|
30
|
-
return filePath;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
it("evicts oldest entry when entry count exceeds limit", async () => {
|
|
34
|
-
const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER);
|
|
35
|
-
const a = writeFrame("a.png", 16);
|
|
36
|
-
const b = writeFrame("b.png", 16);
|
|
37
|
-
const c = writeFrame("c.png", 16);
|
|
38
|
-
|
|
39
|
-
await cache.get(a);
|
|
40
|
-
await cache.get(b);
|
|
41
|
-
expect(cache.stats().entries).toBe(2);
|
|
42
|
-
|
|
43
|
-
await cache.get(c);
|
|
44
|
-
expect(cache.stats().entries).toBe(2);
|
|
45
|
-
expect(cache.stats().evictions).toBe(1);
|
|
46
|
-
|
|
47
|
-
// Verify the *oldest* entry (a) was the victim — the LRU contract.
|
|
48
|
-
// A later get(a) is a miss-then-insert, which would also evict whichever
|
|
49
|
-
// entry is now oldest. We instrument the eviction counter to detect it.
|
|
50
|
-
const evictionsBefore = cache.stats().evictions;
|
|
51
|
-
await cache.get(a);
|
|
52
|
-
expect(cache.stats().evictions).toBe(evictionsBefore + 1);
|
|
53
|
-
// After re-inserting `a`, `b` is the next oldest. `c` is now newest.
|
|
54
|
-
// Touch `b` (move-to-front) → next eviction would be `c`, not `b`.
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("evicts oldest entry when byte budget is exceeded", async () => {
|
|
58
|
-
// 1 KB raw frame → ~1.4 KB base64 + ~22-byte data URI prefix. Pick a
|
|
59
|
-
// budget that comfortably fits two URIs but not three, so the third
|
|
60
|
-
// get() forces eviction even though the entry-count cap (100) is far
|
|
61
|
-
// from the limit.
|
|
62
|
-
const cache = createFrameSourceCache(100, 4 * 1024);
|
|
63
|
-
const a = writeFrame("a.png", 1024);
|
|
64
|
-
const b = writeFrame("b.png", 1024);
|
|
65
|
-
const c = writeFrame("c.png", 1024);
|
|
66
|
-
|
|
67
|
-
await cache.get(a);
|
|
68
|
-
await cache.get(b);
|
|
69
|
-
expect(cache.stats().entries).toBe(2);
|
|
70
|
-
|
|
71
|
-
await cache.get(c);
|
|
72
|
-
const afterC = cache.stats();
|
|
73
|
-
// The byte budget is the contract — the cache MUST stay under it after
|
|
74
|
-
// an insert that would otherwise overflow. Entry count is incidental.
|
|
75
|
-
expect(afterC.bytes).toBeLessThanOrEqual(4 * 1024);
|
|
76
|
-
expect(afterC.entries).toBeLessThan(3);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("returns the served URL untouched when frameSrcResolver yields one", async () => {
|
|
80
|
-
let served: string | null = "/served/frame.png";
|
|
81
|
-
const cache = createFrameSourceCache(4, 64 * 1024, () => served);
|
|
82
|
-
const file = writeFrame("a.png", 256);
|
|
83
|
-
|
|
84
|
-
expect(await cache.get(file)).toBe("/served/frame.png");
|
|
85
|
-
// Cache stays empty because the resolver short-circuits the read.
|
|
86
|
-
expect(cache.stats()).toMatchObject({ entries: 0, bytes: 0 });
|
|
87
|
-
|
|
88
|
-
served = null;
|
|
89
|
-
const dataUri = await cache.get(file);
|
|
90
|
-
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
|
|
91
|
-
expect(cache.stats().entries).toBe(1);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("treats a re-read as a cache hit (no second file read)", async () => {
|
|
95
|
-
const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER);
|
|
96
|
-
const a = writeFrame("a.png", 64);
|
|
97
|
-
|
|
98
|
-
const first = await cache.get(a);
|
|
99
|
-
const second = await cache.get(a);
|
|
100
|
-
expect(second).toBe(first);
|
|
101
|
-
expect(cache.stats().entries).toBe(1);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("skips caching an entry that alone exceeds the byte budget (no self-eviction)", async () => {
|
|
105
|
-
// 64 KB raw → ~88 KB base64 + prefix. Budget of 32 KB rejects this entry.
|
|
106
|
-
// The contract: caller still gets the data URI; cache stays empty so
|
|
107
|
-
// future inserts aren't blocked by the rejected entry's bookkeeping.
|
|
108
|
-
const cache = createFrameSourceCache(100, 32 * 1024);
|
|
109
|
-
const big = writeFrame("big.png", 64 * 1024);
|
|
110
|
-
|
|
111
|
-
const dataUri = await cache.get(big);
|
|
112
|
-
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
|
|
113
|
-
expect(cache.stats().entries).toBe(0);
|
|
114
|
-
expect(cache.stats().bytes).toBe(0);
|
|
115
|
-
expect(cache.stats().oversizedRejections).toBe(1);
|
|
116
|
-
expect(cache.stats().evictions).toBe(0);
|
|
117
|
-
|
|
118
|
-
// A subsequent normal-sized entry must cache cleanly — the rejection
|
|
119
|
-
// path didn't pollute internal state.
|
|
120
|
-
const small = writeFrame("small.png", 1024);
|
|
121
|
-
await cache.get(small);
|
|
122
|
-
expect(cache.stats().entries).toBe(1);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("at the production default (1500 MB), 1080p frames stay cached", async () => {
|
|
126
|
-
// Regression for the post-PR-#662 default: previously the cache held up
|
|
127
|
-
// to 256 entries × ~8 MB ≈ 2 GB at 1080p. The new byte-budget default of
|
|
128
|
-
// 1500 MB caps it tighter (~187 entries at 1080p ≈ 6s @ 30fps). This
|
|
129
|
-
// test pins the math so a future tweak to the default is visible.
|
|
130
|
-
const oneEightyP_jpegSize = 8 * 1024 * 1024; // ~8 MB JPEG (data URI)
|
|
131
|
-
const defaultBytesLimit = DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb * 1024 * 1024;
|
|
132
|
-
const expectedMaxEntries = Math.floor(defaultBytesLimit / oneEightyP_jpegSize);
|
|
133
|
-
expect(expectedMaxEntries).toBeGreaterThanOrEqual(180);
|
|
134
|
-
expect(expectedMaxEntries).toBeLessThanOrEqual(200);
|
|
135
|
-
// At 30fps that's at least 6 seconds of look-ahead. Sequential access is
|
|
136
|
-
// strictly cheaper, so the cache helps any seek-back ≤ 6s.
|
|
137
|
-
expect(expectedMaxEntries / 30).toBeGreaterThanOrEqual(6);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Suppress unused-import warning when the SHARED_STATS sentinel is dropped.
|
|
141
|
-
it("stats() exposes counters used by telemetry", async () => {
|
|
142
|
-
const cache = createFrameSourceCache(1, Number.MAX_SAFE_INTEGER);
|
|
143
|
-
expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
|
|
144
|
-
});
|
|
145
|
-
});
|