mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user