mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user