@hyperframes/engine 0.5.2 → 0.5.4

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.
@@ -0,0 +1,92 @@
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
+ });
@@ -129,12 +129,20 @@ export async function beginFrameCapture(
129
129
  export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise<Buffer> {
130
130
  const client = await getCdpSession(page);
131
131
  const isPng = options.format === "png";
132
+ const dpr = options.deviceScaleFactor ?? 1;
133
+ // When supersampling, pass an explicit clip with `scale` so Chrome emits a
134
+ // screenshot at device-pixel dimensions (`width × height × dpr`). Without
135
+ // this, `Page.captureScreenshot` returns at CSS dimensions regardless of
136
+ // the viewport's deviceScaleFactor.
137
+ const clip =
138
+ dpr > 1 ? { x: 0, y: 0, width: options.width, height: options.height, scale: dpr } : undefined;
132
139
  const result = await client.send("Page.captureScreenshot", {
133
140
  format: isPng ? "png" : "jpeg",
134
141
  quality: isPng ? undefined : (options.quality ?? 80),
135
142
  fromSurface: true,
136
143
  captureBeyondViewport: false,
137
144
  optimizeForSpeed: !isPng,
145
+ ...(clip ? { clip } : {}),
138
146
  });
139
147
  return Buffer.from(result.data, "base64");
140
148
  }
@@ -0,0 +1,145 @@
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
+ });
@@ -15,28 +15,91 @@ 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" | "frameDataUriCacheBytesLimitMb">
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
+ */
23
59
  function createFrameSourceCache(
24
- cacheLimit: number,
60
+ entryLimit: number,
61
+ bytesLimit: number,
25
62
  frameSrcResolver?: (framePath: string) => string | null,
26
- ) {
63
+ ): FrameSourceCache {
27
64
  const cache = new Map<string, string>();
28
65
  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
+ }
29
81
 
30
82
  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
+ }
31
95
  if (cache.has(framePath)) {
96
+ totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
32
97
  cache.delete(framePath);
33
98
  }
34
99
  cache.set(framePath, dataUri);
35
- if (cache.size > cacheLimit) {
36
- const oldestKey = cache.keys().next().value;
37
- if (oldestKey) {
38
- cache.delete(oldestKey);
39
- }
100
+ totalBytes += dataUri.length;
101
+ while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) {
102
+ evictOldest();
40
103
  }
41
104
  return dataUri;
42
105
  }
@@ -70,9 +133,19 @@ function createFrameSourceCache(
70
133
  return pending;
71
134
  }
72
135
 
73
- return { get };
136
+ return {
137
+ get,
138
+ stats: () => ({
139
+ entries: cache.size,
140
+ bytes: totalBytes,
141
+ evictions,
142
+ oversizedRejections,
143
+ }),
144
+ };
74
145
  }
75
146
 
147
+ export const __testing = { createFrameSourceCache };
148
+
76
149
  /**
77
150
  * Creates a BeforeCaptureHook that injects pre-extracted video frames
78
151
  * into the page, replacing native <video> elements with frame images.
@@ -83,11 +156,16 @@ export function createVideoFrameInjector(
83
156
  ): BeforeCaptureHook | null {
84
157
  if (!frameLookup) return null;
85
158
 
86
- const cacheLimit = Math.max(
159
+ const entryLimit = Math.max(
87
160
  32,
88
161
  config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
89
162
  );
90
- const frameCache = createFrameSourceCache(cacheLimit, config?.frameSrcResolver);
163
+ const bytesLimitMb = Math.max(
164
+ 64,
165
+ config?.frameDataUriCacheBytesLimitMb ?? DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb,
166
+ );
167
+ const bytesLimit = bytesLimitMb * 1024 * 1024;
168
+ const frameCache = createFrameSourceCache(entryLimit, bytesLimit, config?.frameSrcResolver);
91
169
  const lastInjectedFrameByVideo = new Map<string, number>();
92
170
 
93
171
  return async (page: Page, time: number) => {