From a4eea984d9ae7e3eb1dd7fe6974ab7a8f675655b Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 02:38:27 +0000 Subject: [PATCH 1/4] feat(cli): add --resolution flag to hyperframes init for 4k scaffolding --- docs/packages/cli.mdx | 1 + packages/cli/src/commands/init.test.ts | 112 +++++++++++++++++++++- packages/cli/src/commands/init.ts | 126 ++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 3 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bf492833f..ca7df21e6 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -162,6 +162,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | Flag | Description | |------|-------------| | `--example, -e` | Example to scaffold (required in default mode, interactive in `--human-friendly`) | + | `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840). Aliases: `1080p`, `4k`, `uhd`. Default: keep template dimensions. | | `--video, -V` | Path to a video file (MP4, WebM, MOV) | | `--audio, -a` | Path to an audio file (MP3, WAV, M4A) | | `--tailwind` | Add Tailwind CSS browser-runtime support to scaffolded HTML | diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 1e905509b..ef7dbf561 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { injectTailwindBrowserScript } from "./init.js"; +import { applyResolutionPreset, injectTailwindBrowserScript } from "./init.js"; const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); const tailwindScript = @@ -146,3 +146,111 @@ describe("hyperframes init flag rename", () => { } }); }); + +describe("applyResolutionPreset", () => { + function withFixture(fn: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "hf-resolution-test-")); + try { + mkdirSync(dir, { recursive: true }); + fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + const sampleHtml = [ + "", + '', + " ", + ' ', + " ", + " ", + " ", + '
', + "
", + " ", + "", + ].join("\n"); + + it("rewrites every dimension fingerprint for landscape-4k", () => { + withFixture((dir) => { + const file = join(dir, "index.html"); + writeFileSync(file, sampleHtml, "utf-8"); + + applyResolutionPreset(dir, "landscape-4k"); + const out = readFileSync(file, "utf-8"); + + expect(out).toContain('data-resolution="landscape-4k"'); + expect(out).toContain('data-width="3840"'); + expect(out).toContain('data-height="2160"'); + expect(out).toContain("width: 3840px"); + expect(out).toContain("height: 2160px"); + expect(out).toContain('content="width=3840, height=2160"'); + expect(out).not.toContain("1920"); + expect(out).not.toContain("1080"); + }); + }); + + it("swaps to portrait dimensions for portrait-4k", () => { + withFixture((dir) => { + const file = join(dir, "index.html"); + writeFileSync(file, sampleHtml, "utf-8"); + + applyResolutionPreset(dir, "portrait-4k"); + const out = readFileSync(file, "utf-8"); + + expect(out).toContain('data-width="2160"'); + expect(out).toContain('data-height="3840"'); + expect(out).toContain('data-resolution="portrait-4k"'); + }); + }); + + it("scaffolds a 4k project end-to-end via --resolution 4k", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); + const target = join(dir, "proj"); + try { + const res = runInit([ + target, + "--example", + "blank", + "--resolution", + "4k", + "--non-interactive", + "--skip-skills", + ]); + expect(res.status).toBe(0); + + const html = readFileSync(join(target, "index.html"), "utf-8"); + expect(html).toContain('data-width="3840"'); + expect(html).toContain('data-height="2160"'); + expect(html).toContain('data-resolution="landscape-4k"'); + expect(html).toContain("width: 3840px"); + expect(html).toContain("height: 2160px"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects an unknown --resolution value", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); + const target = join(dir, "proj"); + try { + const res = runInit([ + target, + "--example", + "blank", + "--resolution", + "8k", + "--non-interactive", + "--skip-skills", + ]); + expect(res.status).toBe(1); + expect(res.stderr).toContain("Invalid --resolution"); + expect(existsSync(target)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 798d20763..4f20a1544 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,6 +4,8 @@ import type { Example } from "./_examples.js"; export const examples: Example[] = [ ["Create a project with the interactive wizard", "hyperframes init my-video"], ["Pick a starter example", "hyperframes init my-video --example warm-grain"], + ["Scaffold a 4K project", "hyperframes init my-video --resolution 4k"], + ["Scaffold a portrait video", "hyperframes init my-video --resolution portrait"], ["Start from an existing video file", "hyperframes init my-video --video clip.mp4"], ["Start from an audio file", "hyperframes init my-video --audio track.mp3"], ["Scaffold with Tailwind CSS", "hyperframes init my-video --example blank --tailwind"], @@ -34,6 +36,34 @@ import { fetchRemoteTemplate } from "../templates/remote.js"; import { trackInitTemplate } from "../telemetry/events.js"; import { hasFFmpeg } from "../whisper/manager.js"; import { VERSION } from "../version.js"; +import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core"; + +const VALID_RESOLUTIONS: readonly CanvasResolution[] = [ + "landscape", + "portrait", + "landscape-4k", + "portrait-4k", +] as const; + +const RESOLUTION_ALIASES: Record = { + "1080p": "landscape", + hd: "landscape", + "1080p-portrait": "portrait", + "portrait-1080p": "portrait", + "4k": "landscape-4k", + uhd: "landscape-4k", + "4k-portrait": "portrait-4k", + "portrait-4k": "portrait-4k", +}; + +function normalizeResolutionFlag(input: string | undefined): CanvasResolution | undefined { + if (!input) return undefined; + const lowered = input.toLowerCase(); + if ((VALID_RESOLUTIONS as readonly string[]).includes(lowered)) { + return lowered as CanvasResolution; + } + return RESOLUTION_ALIASES[lowered]; +} interface VideoMeta { durationSeconds: number; @@ -416,6 +446,70 @@ async function handleVideoFile( return { meta, localVideoName }; } +// --------------------------------------------------------------------------- +// applyResolutionPreset — rewrite stage dimensions in scaffolded HTML +// --------------------------------------------------------------------------- + +/** + * Templates ship with 1920×1080 dimensions baked into multiple places. + * When the user picks a non-default resolution we walk every HTML file in + * the scaffold and rewrite the dimension fingerprint: + * - `data-width` / `data-height` on the root composition + * - `data-resolution` on the element (added when missing) + * - the `width`/`height` declared in the inline `html, body { ... }` CSS + * - the `` tag's `width=`/`height=` parts + * We rewrite by regex rather than DOM-parsing because templates have + * decorative comments and indentation we want to preserve byte-for-byte. + */ +export function applyResolutionPreset(destDir: string, resolution: CanvasResolution): void { + const { width, height } = CANVAS_DIMENSIONS[resolution]; + for (const file of listHtmlFiles(destDir)) { + let html = readFileSync(file, "utf-8"); + let changed = false; + + const dataWidthRe = /(data-width=)["'](\d+)["']/g; + if (dataWidthRe.test(html)) { + html = html.replace(dataWidthRe, `$1"${width}"`); + changed = true; + } + const dataHeightRe = /(data-height=)["'](\d+)["']/g; + if (dataHeightRe.test(html)) { + html = html.replace(dataHeightRe, `$1"${height}"`); + changed = true; + } + + const htmlOpenRe = /]*)>/i; + const htmlOpen = html.match(htmlOpenRe); + if (htmlOpen) { + const attrs = htmlOpen[1] ?? ""; + let next: string; + if (/data-resolution=/.test(attrs)) { + next = attrs.replace(/data-resolution=["'][^"']*["']/, `data-resolution="${resolution}"`); + } else { + next = `${attrs.replace(/\s+$/, "")} data-resolution="${resolution}"`; + } + if (next !== attrs) { + html = html.replace(htmlOpenRe, ``); + changed = true; + } + } + + const bodyCssRe = /(html\s*,\s*body\s*\{[^}]*?width:\s*)\d+px([^}]*?height:\s*)\d+px/i; + if (bodyCssRe.test(html)) { + html = html.replace(bodyCssRe, `$1${width}px$2${height}px`); + changed = true; + } + + const viewportRe = /(]*name=["']viewport["'][^>]*content=["'])width=\d+,\s*height=\d+/i; + if (viewportRe.test(html)) { + html = html.replace(viewportRe, `$1width=${width}, height=${height}`); + changed = true; + } + + if (changed) writeFileSync(file, html, "utf-8"); + } +} + // --------------------------------------------------------------------------- // scaffoldProject — copy template, patch video refs, write meta.json // --------------------------------------------------------------------------- @@ -427,6 +521,7 @@ async function scaffoldProject( localVideoName: string | undefined, durationSeconds?: number, tailwind = false, + resolution?: CanvasResolution, ): Promise { mkdirSync(destDir, { recursive: true }); @@ -439,6 +534,7 @@ async function scaffoldProject( } patchVideoSrc(destDir, localVideoName, durationSeconds); if (tailwind) writeTailwindSupport(destDir); + if (resolution) applyResolutionPreset(destDir, resolution); writeFileSync( resolve(destDir, "meta.json"), @@ -540,6 +636,11 @@ export default defineCommand({ type: "boolean", description: "Add Tailwind CSS browser-runtime support", }, + resolution: { + type: "string", + description: + "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).", + }, }, async run({ args }) { if (args.template !== undefined) { @@ -563,6 +664,20 @@ export default defineCommand({ const languageFlag = args.language; const interactive = !nonInteractive && process.stdout.isTTY === true; + let resolutionPreset: CanvasResolution | undefined; + if (args.resolution !== undefined) { + resolutionPreset = normalizeResolutionFlag(args.resolution); + if (!resolutionPreset) { + console.error( + c.error( + `Invalid --resolution: "${args.resolution}". ` + + `Use one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`, + ), + ); + process.exit(1); + } + } + // ----------------------------------------------------------------------- // Non-interactive mode — all inputs from flags, defaults where missing // ----------------------------------------------------------------------- @@ -645,6 +760,7 @@ export default defineCommand({ localVideoName, videoDuration, tailwind, + resolutionPreset, ); } catch (err) { console.error( @@ -840,7 +956,15 @@ export default defineCommand({ spin.start(`Downloading example ${c.accent(templateId)}...`); } try { - await scaffoldProject(destDir, name, templateId, localVideoName, videoDuration, tailwind); + await scaffoldProject( + destDir, + name, + templateId, + localVideoName, + videoDuration, + tailwind, + resolutionPreset, + ); if (!isBundled) { spin.stop(c.success(`Downloaded ${templateId}`)); } From 9f0074e44a80c851f5771f6012ade59d3828842c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 05:33:22 +0000 Subject: [PATCH 2/4] fix(cli): handle reverse-order CSS in applyResolutionPreset and document scope --- packages/cli/src/commands/init.test.ts | 57 ++++++++++++++++++++++++++ packages/cli/src/commands/init.ts | 26 ++++++++---- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index ef7dbf561..59cfa7ca4 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -253,4 +253,61 @@ describe("applyResolutionPreset", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("rewrites height-before-width inline CSS", () => { + withFixture((dir) => { + const file = join(dir, "index.html"); + // Reversed property order — same as the parser's stageMatchReverse path. + const reversedOrderHtml = sampleHtml.replace( + "html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }", + "html, body { margin: 0; height: 1080px; width: 1920px; overflow: hidden; }", + ); + writeFileSync(file, reversedOrderHtml, "utf-8"); + + applyResolutionPreset(dir, "landscape-4k"); + const out = readFileSync(file, "utf-8"); + + expect(out).toContain("height: 2160px"); + expect(out).toContain("width: 3840px"); + expect(out).not.toContain("1080px"); + expect(out).not.toContain("1920px"); + }); + }); + + it("is a no-op on a file with no dimension fingerprint (does not error)", () => { + withFixture((dir) => { + const file = join(dir, "fragment.html"); + // No data-width/height, no html/body block, no viewport — just markup. + const minimal = "

hi

"; + writeFileSync(file, minimal, "utf-8"); + + expect(() => applyResolutionPreset(dir, "landscape-4k")).not.toThrow(); + const out = readFileSync(file, "utf-8"); + // The htmlOpenRe path adds `data-resolution="landscape-4k"` because + // the tag is present. That's correct: an explicit signal of + // intended resolution survives even when no dim fields exist. + expect(out).toContain('data-resolution="landscape-4k"'); + }); + }); + + it("accepts uppercase --resolution value (4K)", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); + const target = join(dir, "proj"); + try { + const res = runInit([ + target, + "--example", + "blank", + "--resolution", + "4K", + "--non-interactive", + "--skip-skills", + ]); + expect(res.status).toBe(0); + const html = readFileSync(join(target, "index.html"), "utf-8"); + expect(html).toContain('data-width="3840"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4f20a1544..d31cad686 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -451,15 +451,16 @@ async function handleVideoFile( // --------------------------------------------------------------------------- /** - * Templates ship with 1920×1080 dimensions baked into multiple places. - * When the user picks a non-default resolution we walk every HTML file in - * the scaffold and rewrite the dimension fingerprint: - * - `data-width` / `data-height` on the root composition - * - `data-resolution` on the element (added when missing) - * - the `width`/`height` declared in the inline `html, body { ... }` CSS - * - the `` tag's `width=`/`height=` parts - * We rewrite by regex rather than DOM-parsing because templates have - * decorative comments and indentation we want to preserve byte-for-byte. + * Rewrite the canvas dimensions in every scaffolded HTML file to match a + * preset. We rewrite by regex rather than DOM-parsing so template comments + * and indentation survive byte-for-byte — these are review-target files, + * not transient build artifacts. + * + * Scope: HTML files only. Templates whose `#stage` dimensions live in an + * external `.css` stylesheet are not patched — the bundled `blank` template + * inlines its CSS, and that's the convention for new templates. If you + * author a template with external CSS, replicate the dimension swap there + * by hand or move the dimensions inline. */ export function applyResolutionPreset(destDir: string, resolution: CanvasResolution): void { const { width, height } = CANVAS_DIMENSIONS[resolution]; @@ -494,11 +495,18 @@ export function applyResolutionPreset(destDir: string, resolution: CanvasResolut } } + // Inline `html, body { ... }` CSS: handle width-before-height and + // height-before-width orderings. Hand-authored templates can use either. const bodyCssRe = /(html\s*,\s*body\s*\{[^}]*?width:\s*)\d+px([^}]*?height:\s*)\d+px/i; if (bodyCssRe.test(html)) { html = html.replace(bodyCssRe, `$1${width}px$2${height}px`); changed = true; } + const bodyCssReverseRe = /(html\s*,\s*body\s*\{[^}]*?height:\s*)\d+px([^}]*?width:\s*)\d+px/i; + if (bodyCssReverseRe.test(html)) { + html = html.replace(bodyCssReverseRe, `$1${height}px$2${width}px`); + changed = true; + } const viewportRe = /(]*name=["']viewport["'][^>]*content=["'])width=\d+,\s*height=\d+/i; if (viewportRe.test(html)) { From 8203005488b877aea8922905b06f8c1d9e144d7c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 02:46:45 +0000 Subject: [PATCH 3/4] 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