mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
## 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.
97 lines
2.5 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|