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..59cfa7ca4 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,168 @@ 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 });
+ }
+ });
+
+ 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 798d20763..d31cad686 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,78 @@ async function handleVideoFile(
return { meta, localVideoName };
}
+// ---------------------------------------------------------------------------
+// applyResolutionPreset — rewrite stage dimensions in scaffolded HTML
+// ---------------------------------------------------------------------------
+
+/**
+ * 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];
+ 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;
+ }
+ }
+
+ // 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)) {
+ 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 +529,7 @@ async function scaffoldProject(
localVideoName: string | undefined,
durationSeconds?: number,
tailwind = false,
+ resolution?: CanvasResolution,
): Promise {
mkdirSync(destDir, { recursive: true });
@@ -439,6 +542,7 @@ async function scaffoldProject(
}
patchVideoSrc(destDir, localVideoName, durationSeconds);
if (tailwind) writeTailwindSupport(destDir);
+ if (resolution) applyResolutionPreset(destDir, resolution);
writeFileSync(
resolve(destDir, "meta.json"),
@@ -540,6 +644,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 +672,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 +768,7 @@ export default defineCommand({
localVideoName,
videoDuration,
tailwind,
+ resolutionPreset,
);
} catch (err) {
console.error(
@@ -840,7 +964,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}`));
}
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..28c813641
--- /dev/null
+++ b/packages/engine/src/services/videoFrameInjector.test.ts
@@ -0,0 +1,145 @@
+// @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";
+import { DEFAULT_CONFIG } from "../config.js";
+
+const { createFrameSourceCache } = __testing;
+
+const SHARED_STATS = { evictions: 0, oversizedRejections: 0 };
+
+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);
+ expect(cache.stats().evictions).toBe(1);
+
+ // Verify the *oldest* entry (a) was the victim — the LRU contract.
+ // A later get(a) is a miss-then-insert, which would also evict whichever
+ // entry is now oldest. We instrument the eviction counter to detect it.
+ const evictionsBefore = cache.stats().evictions;
+ await cache.get(a);
+ expect(cache.stats().evictions).toBe(evictionsBefore + 1);
+ // After re-inserting `a`, `b` is the next oldest. `c` is now newest.
+ // Touch `b` (move-to-front) → next eviction would be `c`, not `b`.
+ });
+
+ 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()).toMatchObject({ 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);
+ });
+
+ it("skips caching an entry that alone exceeds the byte budget (no self-eviction)", async () => {
+ // 64 KB raw → ~88 KB base64 + prefix. Budget of 32 KB rejects this entry.
+ // The contract: caller still gets the data URI; cache stays empty so
+ // future inserts aren't blocked by the rejected entry's bookkeeping.
+ const cache = createFrameSourceCache(100, 32 * 1024);
+ const big = writeFrame("big.png", 64 * 1024);
+
+ const dataUri = await cache.get(big);
+ expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
+ expect(cache.stats().entries).toBe(0);
+ expect(cache.stats().bytes).toBe(0);
+ expect(cache.stats().oversizedRejections).toBe(1);
+ expect(cache.stats().evictions).toBe(0);
+
+ // A subsequent normal-sized entry must cache cleanly — the rejection
+ // path didn't pollute internal state.
+ const small = writeFrame("small.png", 1024);
+ await cache.get(small);
+ expect(cache.stats().entries).toBe(1);
+ });
+
+ it("at the production default (1500 MB), 1080p frames stay cached", async () => {
+ // Regression for the post-PR-#662 default: previously the cache held up
+ // to 256 entries × ~8 MB ≈ 2 GB at 1080p. The new byte-budget default of
+ // 1500 MB caps it tighter (~187 entries at 1080p ≈ 6s @ 30fps). This
+ // test pins the math so a future tweak to the default is visible.
+ const oneEightyP_jpegSize = 8 * 1024 * 1024; // ~8 MB JPEG (data URI)
+ const defaultBytesLimit = DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb * 1024 * 1024;
+ const expectedMaxEntries = Math.floor(defaultBytesLimit / oneEightyP_jpegSize);
+ expect(expectedMaxEntries).toBeGreaterThanOrEqual(180);
+ expect(expectedMaxEntries).toBeLessThanOrEqual(200);
+ // At 30fps that's at least 6 seconds of look-ahead. Sequential access is
+ // strictly cheaper, so the cache helps any seek-back ≤ 6s.
+ expect(expectedMaxEntries / 30).toBeGreaterThanOrEqual(6);
+ });
+
+ // Suppress unused-import warning when the SHARED_STATS sentinel is dropped.
+ it("stats() exposes counters used by telemetry", async () => {
+ const cache = createFrameSourceCache(1, Number.MAX_SAFE_INTEGER);
+ expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
+ });
+});
diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts
index befbbd19c..b928decf9 100644
--- a/packages/engine/src/services/videoFrameInjector.ts
+++ b/packages/engine/src/services/videoFrameInjector.ts
@@ -15,28 +15,92 @@ 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;
+ /** Total entries evicted since cache creation. A high count vs a small
+ * composition signals the byte budget is too tight (cache thrash). */
+ evictions: number;
+ /** Total inserts rejected because the entry alone exceeds bytesLimit.
+ * Non-zero means a single frame is bigger than the configured budget —
+ * raise `frameDataUriCacheBytesLimitMb` if it recurs in production. */
+ oversizedRejections: number;
+}
+
+interface FrameSourceCache {
+ get: (framePath: string) => Promise;
+ /** Exposed for tests + telemetry; reflects 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).
+ *
+ * If a single entry's data URI exceeds `bytesLimit`, we skip caching it
+ * (returning the URI directly to the caller). Without this guard, the
+ * post-insert eviction loop would drop the entry we just inserted and the
+ * cache would degrade into a CPU hot path — every subsequent `get()` would
+ * re-read from disk and re-base64 the same frame. The lost cache hit costs
+ * one re-read per access; pretending to cache and immediately evicting
+ * costs one re-read per access *plus* the futile insert/evict bookkeeping.
+ */
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;
+ let evictions = 0;
+ let oversizedRejections = 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);
+ evictions++;
+ }
function remember(framePath: string, dataUri: string): string {
+ // Skip caching entries that alone exceed the byte budget. Caching them
+ // would trigger immediate self-eviction on insert and pollute LRU order
+ // by displacing the previous entry's slot.
+ if (dataUri.length > bytesLimit) {
+ oversizedRejections++;
+ // Drop any stale prior version so the caller sees consistent state.
+ if (cache.has(framePath)) {
+ const prev = sizes.get(framePath) ?? 0;
+ cache.delete(framePath);
+ sizes.delete(framePath);
+ totalBytes = Math.max(0, totalBytes - prev);
+ }
+ return dataUri;
+ }
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 +134,19 @@ function createFrameSourceCache(
return pending;
}
- return { get };
+ return {
+ get,
+ stats: () => ({
+ entries: cache.size,
+ bytes: totalBytes,
+ evictions,
+ oversizedRejections,
+ }),
+ };
}
+export const __testing = { createFrameSourceCache };
+
/**
* Creates a BeforeCaptureHook that injects pre-extracted video frames
* into the page, replacing native