perf(engine): content-addressed extraction cache for video frames (#446)

## What

Adds a content-addressed cache for extracted video frames, keyed on the tuple `(path, mtime, size, mediaStart, duration, fps, format)`. Repeat renders of the same composition (studio edit → re-render, preview → final) skip the ffmpeg extraction entirely.

## Why

Video frame extraction is the dominant non-capture phase for video-heavy compositions. Studio iteration workflows extract the same frames over and over — each render burns ffmpeg time that adds no value.

Validated on `/tmp/hf-fixtures/cfr-sdr-cache`:
```
Cold (miss): extractMs=69,  videoExtractMs=70,  totalElapsedMs=2052
Warm (hit):  extractMs=1,   videoExtractMs=2,   totalElapsedMs=1964
cacheHits: 0→1, cacheMisses: 1→0
```
The fixture is tiny (3s CFR SDR @ 30fps), so the wall-clock delta is small; the extraction-time delta (69→1ms, 98%) scales linearly with source length. For heavy-iteration workflows (a user rendering the same composition while tuning encoding params), extraction time goes to zero on every repeat render.

Depends on #444 (instrumentation surface) and #445 (segment-scope HDR preflight — otherwise cache keys would be unstable across renders on mixed-HDR compositions).

## How

- New `packages/engine/src/services/extractionCache.ts`:
  - SHA-256 key over a stable JSON encoding of `(path, mtime_ms, size, mediaStart, duration, fps, format)`. Infinity duration is normalized to `-1` so unresolved natural-duration sources still produce stable keys.
  - Truncates to 16 hex chars in the entry directory name — 64 bits of entropy is plenty at cache scale and keeps `ls` output short.
  - `hfcache-v2-` schema prefix — bumping it invalidates old entries (callers own gc policy; the cache owns keys).
  - `.hf-complete` dotfile sentinel. An entry dir without the sentinel is treated as a miss (covers crash-mid-extract and abandoned writes); the next render re-extracts over the partial frames with `-y`.
  - `FRAME_FILENAME_PREFIX = "frame_"` shared with the extractor — future refactors only need to touch one place to rename frames.
- `EngineConfig.extractCacheDir` (env: `HYPERFRAMES_EXTRACT_CACHE_DIR`) gates the feature. Undefined disables caching — extraction runs into the render's workDir and cleanup removes it on render end, preserving the prior behaviour exactly. No default root is chosen by the engine; the caller (CLI, app, studio) owns the location policy.
- `ExtractedFrames.ownedByLookup` flag prevents `FrameLookupTable.cleanup` from rm'ing a shared cache dir at render end. Set to `true` on both hits and misses (misses own the directory they wrote into, but hand it over to the cache rather than deleting it).
- Phase 3 extractor flow:
  1. Snapshot `(videoPath, mediaStart, start, end)` per resolved video BEFORE Phase 2a/2b preflight mutates them — so cache keys are stable across renders that use workDir-local normalized files (those files have fresh mtimes every render).
  2. Compute key, `lookupCacheEntry`.
  3. On hit: rebuild `ExtractedFrames` from the cache dir plus the Phase 2-probed `VideoMetadata` — no re-ffprobe.
  4. On miss: `ensureCacheEntryDir`, extract with `extractVideoFramesRange(..., outputDirOverride)`, then `markCacheEntryComplete` (the sentinel write is the last step so a crash leaves the dir un-sentineled).
- `extractVideoFramesRange` gains an `outputDirOverride` parameter so cache-miss writes land directly in the keyed dir (no `join(outputDir, videoId)` wrapping).

## Test plan

- [x] 19 unit tests in `extractionCache.test.ts` covering key determinism, mtime/size invalidation, format/fps/mediaStart/duration invalidation, Infinity normalization, sentinel semantics, missing-file tolerance
- [x] 2 integration tests in `videoFrameExtractor.test.ts`:
  - "reuses extracted frames on a warm cache hit" — asserts `cacheHits=1`, `extractMs<50ms` on second call against a CFR SDR fixture
  - "invalidates the cache when fps changes" — different fps on second call forces a new miss
- [x] End-to-end validation with `HYPERFRAMES_EXTRACT_CACHE_DIR` set, two runs of the same fixture
- [x] Lint + format (oxlint + oxfmt)
- [x] Typecheck (engine + producer)
This commit is contained in:
James Russo
2026-04-24 00:35:31 -04:00
committed by GitHub
parent 9912d3730a
commit 57cdf7d80e
6 changed files with 715 additions and 15 deletions
@@ -18,6 +18,15 @@ import {
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import {
FRAME_FILENAME_PREFIX,
ensureCacheEntryDir,
lookupCacheEntry,
markCacheEntryComplete,
readKeyStat,
rehydrateCacheEntry,
type CacheFrameFormat,
} from "./extractionCache.js";
export interface VideoElement {
id: string;
@@ -37,6 +46,13 @@ export interface ExtractedFrames {
totalFrames: number;
metadata: VideoMetadata;
framePaths: Map<number, string>;
/**
* True when the extractor owns `outputDir` and cleanup should rm it when
* the render ends. Cache hits set this to false so the shared entry isn't
* deleted by a single render's cleanup — the cache dir is owned by the
* caller's gc policy, not any one render.
*/
ownedByLookup?: boolean;
}
export interface ExtractionOptions {
@@ -181,15 +197,22 @@ export async function extractVideoFramesRange(
options: ExtractionOptions,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
/**
* Override the output directory for this extraction. When provided, frames
* are written directly into `outputDirOverride` (no per-videoId subdir).
* Used by the cache layer to materialize frames straight into the keyed
* cache entry directory.
*/
outputDirOverride?: string,
): Promise<ExtractedFrames> {
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const { fps, outputDir, quality = 95, format = "jpg" } = options;
const videoOutputDir = join(outputDir, videoId);
const videoOutputDir = outputDirOverride ?? join(outputDir, videoId);
if (!existsSync(videoOutputDir)) mkdirSync(videoOutputDir, { recursive: true });
const metadata = await extractMediaMetadata(videoPath);
const framePattern = `frame_%05d.${format}`;
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
const outputPattern = join(videoOutputDir, framePattern);
// When extracting from HDR source, tone-map to SDR in FFmpeg rather than
@@ -253,7 +276,7 @@ export async function extractVideoFramesRange(
const framePaths = new Map<number, string>();
const files = readdirSync(videoOutputDir)
.filter((f) => f.startsWith("frame_") && f.endsWith(`.${format}`))
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(`.${format}`))
.sort();
files.forEach((file, index) => {
framePaths.set(index, join(videoOutputDir, file));
@@ -353,6 +376,21 @@ async function convertSdrToHdr(
}
}
/**
* Resolve the used-segment duration for a video, falling back to the source's
* natural duration when the caller hasn't specified bounds (end=Infinity) or
* the bounds are nonsensical (end<=start).
*/
function resolveSegmentDuration(
requested: number,
mediaStart: number,
metadata: VideoMetadata,
): number {
if (Number.isFinite(requested) && requested > 0) return requested;
const sourceRemaining = metadata.durationSeconds - mediaStart;
return sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
}
/**
* Re-encode a VFR (variable frame rate) video segment to CFR so the downstream
* fps filter can extract frames reliably. Screen recordings, phone videos, and
@@ -414,7 +452,7 @@ export async function extractAllVideoFrames(
baseDir: string,
options: ExtractionOptions,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "extractCacheDir">>,
compiledDir?: string,
): Promise<ExtractionResult> {
const startTime = Date.now();
@@ -469,6 +507,28 @@ export async function extractAllVideoFrames(
breakdown.resolveMs = Date.now() - phase1Start;
// Snapshot the pre-preflight key inputs so the extraction cache keys on the
// user-visible source (original path, original mediaStart, original segment
// bounds) rather than the workDir-local normalized file produced by
// Phase 2a/2b preflight. Without this, every render would write a new
// normalized file with a fresh mtime → fresh cache key → perpetual misses.
const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => {
const stat = readKeyStat(videoPath);
// Missing files return null — skip the cache path for that entry. The
// extractor will surface the real file-not-found error downstream, and we
// avoid polluting the cache with a `(mtimeMs: 0, size: 0)` tuple that two
// unrelated missing paths would otherwise share.
if (!stat) return null;
return {
videoPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
mediaStart: video.mediaStart,
start: video.start,
end: video.end,
};
});
// Phase 2: Probe color spaces and normalize if mixed HDR/SDR
const phase2ProbeStart = Date.now();
const videoMetadata = await Promise.all(
@@ -565,6 +625,10 @@ export async function extractAllVideoFrames(
resolvedVideos.splice(i, 1);
videoMetadata.splice(i, 1);
videoColorSpaces.splice(i, 1);
// Added by the extraction-cache commit: keep cacheKeyInputs aligned
// with the other parallel arrays so Phase 3's `cacheKeyInputs[i]`
// lookup doesn't point at a stale slot after the splice.
cacheKeyInputs.splice(i, 1);
}
}
}
@@ -615,25 +679,85 @@ export async function extractAllVideoFrames(
}
breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart;
// Phase 3: Extract frames (parallel)
const phase3Start = Date.now();
const cacheRootDir = config?.extractCacheDir;
const cacheFormat: CacheFrameFormat = options.format ?? "jpg";
async function tryCachedExtract(
video: VideoElement,
videoPath: string,
videoDuration: number,
i: number,
): Promise<ExtractedFrames | null> {
if (!cacheRootDir) return null;
const keyInput = cacheKeyInputs[i];
const probedMeta = videoMetadata[i];
if (!keyInput || !probedMeta) return null;
const keyDuration = resolveSegmentDuration(
keyInput.end - keyInput.start,
keyInput.mediaStart,
probedMeta,
);
const lookup = lookupCacheEntry(cacheRootDir, {
videoPath: keyInput.videoPath,
mtimeMs: keyInput.mtimeMs,
size: keyInput.size,
mediaStart: keyInput.mediaStart,
duration: keyDuration,
fps: options.fps,
format: cacheFormat,
});
if (lookup.hit) {
breakdown.cacheHits += 1;
const rehydrated = rehydrateCacheEntry(lookup.entry, {
videoId: video.id,
srcPath: keyInput.videoPath,
fps: options.fps,
format: cacheFormat,
metadata: probedMeta,
});
return { ...rehydrated, ownedByLookup: true };
}
breakdown.cacheMisses += 1;
ensureCacheEntryDir(lookup.entry);
const result = await extractVideoFramesRange(
videoPath,
video.id,
video.mediaStart,
videoDuration,
options,
signal,
config,
lookup.entry.dir,
);
// Mark complete only AFTER frames are on disk — a crash mid-extract
// leaves the entry un-sentineled so the next lookup re-extracts over it.
markCacheEntryComplete(lookup.entry);
return { ...result, ownedByLookup: true };
}
const results = await Promise.all(
resolvedVideos.map(async ({ video, videoPath }) => {
resolvedVideos.map(async ({ video, videoPath }, i) => {
if (signal?.aborted) {
throw new Error("Video frame extraction cancelled");
}
try {
let videoDuration = video.end - video.start;
// Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
// probe the actual video file to get its natural duration.
if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
const metadata = await extractMediaMetadata(videoPath);
const sourceDuration = metadata.durationSeconds - video.mediaStart;
videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
const probedMeta = videoMetadata[i] ?? (await extractMediaMetadata(videoPath));
const videoDuration = resolveSegmentDuration(
video.end - video.start,
video.mediaStart,
probedMeta,
);
if (video.end - video.start !== videoDuration) {
video.end = video.start + videoDuration;
}
const cached = await tryCachedExtract(video, videoPath, videoDuration, i);
if (cached) return { result: cached };
const result = await extractVideoFramesRange(
videoPath,
video.id,
@@ -800,6 +924,10 @@ export class FrameLookupTable {
cleanup(): void {
for (const video of this.videos.values()) {
// Cache-hit / cache-write entries are owned by the extraction cache —
// a single render must not delete them, or the next render's lookup
// would miss and re-extract unnecessarily.
if (video.extracted.ownedByLookup) continue;
if (existsSync(video.extracted.outputDir)) {
rmSync(video.extracted.outputDir, { recursive: true, force: true });
}