From 8203005488b877aea8922905b06f8c1d9e144d7c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 02:46:45 +0000 Subject: [PATCH] fix(engine): byte-budget the frame data uri cache to bound memory at 4k --- packages/engine/src/config.ts | 22 +++++ .../src/services/videoFrameInjector.test.ts | 90 +++++++++++++++++++ .../engine/src/services/videoFrameInjector.ts | 64 ++++++++++--- .../src/services/renderOrchestrator.test.ts | 1 + .../src/services/renderOrchestrator.ts | 1 + 5 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 packages/engine/src/services/videoFrameInjector.test.ts diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index c2f9bc130..c2738ee94 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -76,7 +76,21 @@ export interface EngineConfig { // ── Media ──────────────────────────────────────────────────────────── audioGain: number; + /** + * Hard upper bound on entries kept in the video frame data URI cache. + * Acts as a sanity cap; the byte budget below normally fires first on + * high-resolution renders. At 1080p with ~6 MB per JPEG frame the default + * 256 entries fit inside ~1.5 GB. At 4K the byte budget evicts long + * before this cap is reached. + */ frameDataUriCacheLimit: number; + /** + * Memory budget for the cache, in megabytes. Eviction kicks in once the + * sum of cached data-URI string lengths exceeds this. Sized so a worker + * stays comfortably under a few GB even at 4K (where each PNG frame is + * ~25 MB and the base64 data URI is ~33 MB). + */ + frameDataUriCacheBytesLimitMb: number; // ── Timeouts ───────────────────────────────────────────────────────── playerReadyTimeout: number; @@ -149,6 +163,7 @@ export const DEFAULT_CONFIG: EngineConfig = { audioGain: 1, frameDataUriCacheLimit: 256, + frameDataUriCacheBytesLimitMb: 1500, playerReadyTimeout: 45_000, renderReadyTimeout: 15_000, @@ -246,6 +261,13 @@ export function resolveConfig(overrides?: Partial): EngineConfig { 32, envNum("PRODUCER_FRAME_DATA_URI_CACHE_LIMIT", DEFAULT_CONFIG.frameDataUriCacheLimit), ), + frameDataUriCacheBytesLimitMb: Math.max( + 64, + envNum( + "PRODUCER_FRAME_DATA_URI_CACHE_BYTES_MB", + DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb, + ), + ), playerReadyTimeout: envNum( "PRODUCER_PLAYER_READY_TIMEOUT_MS", diff --git a/packages/engine/src/services/videoFrameInjector.test.ts b/packages/engine/src/services/videoFrameInjector.test.ts new file mode 100644 index 000000000..ad3e1f812 --- /dev/null +++ b/packages/engine/src/services/videoFrameInjector.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { __testing } from "./videoFrameInjector.js"; + +const { createFrameSourceCache } = __testing; + +describe("frame source cache eviction", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-frame-cache-test-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + // Each PNG is base64-encoded into the data URI, so the cached string is + // ~4/3 the file size plus a small `data:image/png;base64,` prefix. Build + // distinct files so eviction has predictable victims. + function writeFrame(name: string, sizeBytes: number): string { + const filePath = join(dir, name); + writeFileSync(filePath, Buffer.alloc(sizeBytes, 0)); + return filePath; + } + + it("evicts oldest entry when entry count exceeds limit", async () => { + const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER); + const a = writeFrame("a.png", 16); + const b = writeFrame("b.png", 16); + const c = writeFrame("c.png", 16); + + await cache.get(a); + await cache.get(b); + expect(cache.stats().entries).toBe(2); + + await cache.get(c); + expect(cache.stats().entries).toBe(2); + }); + + it("evicts oldest entry when byte budget is exceeded", async () => { + // 1 KB raw frame → ~1.4 KB base64 + ~22-byte data URI prefix. Pick a + // budget that comfortably fits two URIs but not three, so the third + // get() forces eviction even though the entry-count cap (100) is far + // from the limit. + const cache = createFrameSourceCache(100, 4 * 1024); + const a = writeFrame("a.png", 1024); + const b = writeFrame("b.png", 1024); + const c = writeFrame("c.png", 1024); + + await cache.get(a); + await cache.get(b); + expect(cache.stats().entries).toBe(2); + + await cache.get(c); + const afterC = cache.stats(); + // The byte budget is the contract — the cache MUST stay under it after + // an insert that would otherwise overflow. Entry count is incidental. + expect(afterC.bytes).toBeLessThanOrEqual(4 * 1024); + expect(afterC.entries).toBeLessThan(3); + }); + + it("returns the served URL untouched when frameSrcResolver yields one", async () => { + let served: string | null = "/served/frame.png"; + const cache = createFrameSourceCache(4, 64 * 1024, () => served); + const file = writeFrame("a.png", 256); + + expect(await cache.get(file)).toBe("/served/frame.png"); + // Cache stays empty because the resolver short-circuits the read. + expect(cache.stats()).toEqual({ entries: 0, bytes: 0 }); + + served = null; + const dataUri = await cache.get(file); + expect(dataUri.startsWith("data:image/png;base64,")).toBe(true); + expect(cache.stats().entries).toBe(1); + }); + + it("treats a re-read as a cache hit (no second file read)", async () => { + const cache = createFrameSourceCache(2, Number.MAX_SAFE_INTEGER); + const a = writeFrame("a.png", 64); + + const first = await cache.get(a); + const second = await cache.get(a); + expect(second).toBe(first); + expect(cache.stats().entries).toBe(1); + }); +}); diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts index befbbd19c..41707237a 100644 --- a/packages/engine/src/services/videoFrameInjector.ts +++ b/packages/engine/src/services/videoFrameInjector.ts @@ -15,28 +15,60 @@ import { type BeforeCaptureHook } from "./frameCapture.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; export interface VideoFrameInjectorOptions extends Partial< - Pick + Pick > { frameSrcResolver?: (framePath: string) => string | null; } +interface FrameSourceCacheStats { + entries: number; + bytes: number; +} + +interface FrameSourceCache { + get: (framePath: string) => Promise; + /** Exposed for tests; reflects the current cache occupancy. */ + stats: () => FrameSourceCacheStats; +} + +/** + * Two-bound LRU keyed by frame path. Either bound triggers eviction of the + * oldest entry — entry count protects against pathological many-tiny-frames + * cases, and the byte budget keeps memory bounded when the per-frame data + * URI grows (4K PNG frames are ~33 MB once base64-encoded). + */ function createFrameSourceCache( - cacheLimit: number, + entryLimit: number, + bytesLimit: number, frameSrcResolver?: (framePath: string) => string | null, -) { +): FrameSourceCache { const cache = new Map(); + const sizes = new Map(); const inFlight = new Map>(); + let totalBytes = 0; + + function evictOldest(): void { + const oldestKey = cache.keys().next().value; + if (!oldestKey) return; + const size = sizes.get(oldestKey) ?? 0; + cache.delete(oldestKey); + sizes.delete(oldestKey); + totalBytes = Math.max(0, totalBytes - size); + } function remember(framePath: string, dataUri: string): string { if (cache.has(framePath)) { + const prev = sizes.get(framePath) ?? 0; cache.delete(framePath); + sizes.delete(framePath); + totalBytes = Math.max(0, totalBytes - prev); } + const size = dataUri.length; cache.set(framePath, dataUri); - if (cache.size > cacheLimit) { - const oldestKey = cache.keys().next().value; - if (oldestKey) { - cache.delete(oldestKey); - } + sizes.set(framePath, size); + totalBytes += size; + while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) { + evictOldest(); } return dataUri; } @@ -70,9 +102,14 @@ function createFrameSourceCache( return pending; } - return { get }; + return { + get, + stats: () => ({ entries: cache.size, bytes: totalBytes }), + }; } +export const __testing = { createFrameSourceCache }; + /** * Creates a BeforeCaptureHook that injects pre-extracted video frames * into the page, replacing native