Files
hyperframes/packages/producer/src/services/hdrImageTransferCache.ts
T
Vance Ingalls bc27f9cee3 perf(producer): cache transfer-converted hdr image buffers per render job (#384)
## Summary

Add `HdrImageTransferCache` — a per-render-job bounded LRU keyed by `(imageId, targetTransfer)` — so static HDR image layers whose source transfer differs from the render's effective transfer (PQ↔HLG) are converted **once per job** instead of **once per composited frame**.

## Why

`Chunk 8B` of `plans/hdr-followups.md`. `blitHdrImageLayer` was running `Buffer.from` + `convertTransfer` on every composited frame, even though the converted buffer is identical for the entire job. For a multi-second comp at 30 fps this is hundreds of redundant transfer conversions on the hot path.

## What changed

- New `packages/producer/src/services/hdrImageTransferCache.ts` — bounded LRU keyed by `(imageId, targetTransfer)` that owns the converted HDR rgb48 buffer for static HDR image layers:
  - Same-transfer requests return the source buffer untouched (zero copy).
  - Cross-transfer requests pay one `Buffer.from` + `convertTransfer` on first miss, reuse the cached copy on every subsequent frame.
- Wired into `renderOrchestrator.ts` via `HdrCompositeContext.hdrImageTransferCache`, instantiated once per render job, and consumed by `blitHdrImageLayer` on both the main composite path and the transition path.

## Test plan

- [x] `packages/producer/src/services/hdrImageTransferCache.test.ts` — 12 tests:
  - hit/miss semantics
  - distinct keys per image and per target transfer
  - LRU eviction + promotion
  - `maxEntries=0` passthrough
  - source-buffer immutability for cached entries
  - invalid options
- [x] Re-ran the Chunk 8A HDR benchmark — for the `hdr-regression` fixture (which has cross-transfer image layers) the cache hits 100% after the first frame; for HDR fixtures without cross-transfer images the same-transfer passthrough is a no-op.

## Stack

Chunk 8B of `plans/hdr-followups.md`. Sits on top of Chunk 8C (logger gating) and Chunk 8A (benchmark harness) so the win is measurable.
2026-04-24 00:15:12 -07:00

97 lines
2.5 KiB
TypeScript

import { type HdrTransfer, convertTransfer } from "@hyperframes/engine";
export interface HdrImageTransferCache {
getConverted(
imageId: string,
sourceTransfer: HdrTransfer,
targetTransfer: HdrTransfer,
source: Buffer,
): Buffer;
size(): number;
bytesUsed(): number;
}
export interface HdrImageTransferCacheOptions {
/**
* Maximum bytes of converted buffers to retain before evicting the
* least-recently-used entries. Defaults to 200 MB. At 1080p (~12 MB/entry)
* that fits ~16 entries; at 4K (~50 MB/entry) it naturally caps at ~4.
* Set to `0` to disable caching entirely (every call allocates fresh).
*/
maxBytes?: number;
}
const DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
export function createHdrImageTransferCache(
options: HdrImageTransferCacheOptions = {},
): HdrImageTransferCache {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
if (!Number.isInteger(maxBytes) || maxBytes < 0) {
throw new Error(
`createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`,
);
}
const entries = new Map<string, Buffer>();
let totalBytes = 0;
function makeKey(imageId: string, targetTransfer: HdrTransfer): string {
return `${imageId}|${targetTransfer}`;
}
function evictUntilRoom(needed: number): void {
while (totalBytes + needed > maxBytes && entries.size > 0) {
const lruKey = entries.keys().next().value;
if (lruKey === undefined) break;
const evicted = entries.get(lruKey);
if (evicted) totalBytes -= evicted.byteLength;
entries.delete(lruKey);
}
}
return {
getConverted(imageId, sourceTransfer, targetTransfer, source) {
if (sourceTransfer === targetTransfer) {
return source;
}
if (maxBytes === 0) {
const fresh = Buffer.from(source);
convertTransfer(fresh, sourceTransfer, targetTransfer);
return fresh;
}
const key = makeKey(imageId, targetTransfer);
const existing = entries.get(key);
if (existing) {
entries.delete(key);
entries.set(key, existing);
return existing;
}
const converted = Buffer.from(source);
convertTransfer(converted, sourceTransfer, targetTransfer);
if (converted.byteLength > maxBytes) {
return converted;
}
evictUntilRoom(converted.byteLength);
entries.set(key, converted);
totalBytes += converted.byteLength;
return converted;
},
size() {
return entries.size;
},
bytesUsed() {
return totalBytes;
},
};
}