mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## 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)
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
import {
|
|
COMPLETE_SENTINEL,
|
|
FRAME_FILENAME_PREFIX,
|
|
SCHEMA_PREFIX,
|
|
cacheEntryDirName,
|
|
computeCacheKey,
|
|
ensureCacheEntryDir,
|
|
lookupCacheEntry,
|
|
markCacheEntryComplete,
|
|
readKeyStat,
|
|
type CacheKeyInput,
|
|
} from "./extractionCache.js";
|
|
|
|
const keyFor = (videoPath: string, overrides: Partial<CacheKeyInput> = {}): CacheKeyInput => {
|
|
const stat = readKeyStat(videoPath);
|
|
if (!stat) throw new Error(`keyFor fixture missing on disk: ${videoPath}`);
|
|
return {
|
|
videoPath,
|
|
mtimeMs: stat.mtimeMs,
|
|
size: stat.size,
|
|
mediaStart: 0,
|
|
duration: 3,
|
|
fps: 30,
|
|
format: "jpg",
|
|
...overrides,
|
|
};
|
|
};
|
|
|
|
describe("extractionCache constants", () => {
|
|
it("exposes the v2 schema prefix", () => {
|
|
expect(SCHEMA_PREFIX).toBe("hfcache-v2-");
|
|
});
|
|
|
|
it("exposes the frame filename prefix shared with the extractor", () => {
|
|
expect(FRAME_FILENAME_PREFIX).toBe("frame_");
|
|
});
|
|
|
|
it("uses a dotfile sentinel so ls-without-A hides it", () => {
|
|
expect(COMPLETE_SENTINEL.startsWith(".")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("computeCacheKey", () => {
|
|
let tmpRoot: string;
|
|
let sourceFile: string;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
|
sourceFile = join(tmpRoot, "clip.mp4");
|
|
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
const base = (videoPath: string): CacheKeyInput => keyFor(videoPath);
|
|
|
|
it("returns the same key for identical inputs", () => {
|
|
const a = computeCacheKey(base(sourceFile));
|
|
const b = computeCacheKey(base(sourceFile));
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("produces a 64-char hex SHA-256 digest", () => {
|
|
const key = computeCacheKey(base(sourceFile));
|
|
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
it("changes when path changes (moved files re-extract)", () => {
|
|
const other = join(tmpRoot, "other.mp4");
|
|
writeFileSync(other, "fake-video-bytes", "utf-8");
|
|
const a = computeCacheKey(base(sourceFile));
|
|
const b = computeCacheKey(base(other));
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("changes when mediaStart changes", () => {
|
|
const a = computeCacheKey(base(sourceFile));
|
|
const b = computeCacheKey({ ...base(sourceFile), mediaStart: 1 });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("changes when duration changes", () => {
|
|
const a = computeCacheKey(base(sourceFile));
|
|
const b = computeCacheKey({ ...base(sourceFile), duration: 5 });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("changes when fps changes (different frame count invalidates key)", () => {
|
|
const a = computeCacheKey(base(sourceFile));
|
|
const b = computeCacheKey({ ...base(sourceFile), fps: 60 });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("changes when format changes", () => {
|
|
const a = computeCacheKey(base(sourceFile));
|
|
const b = computeCacheKey({ ...base(sourceFile), format: "png" });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("normalizes non-finite duration so Infinity doesn't produce unstable keys", () => {
|
|
const a = computeCacheKey({ ...base(sourceFile), duration: Infinity });
|
|
const b = computeCacheKey({ ...base(sourceFile), duration: Infinity });
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("changes when file content changes (mtime+size bump)", () => {
|
|
const before = computeCacheKey(base(sourceFile));
|
|
// Force an mtime change by waiting 5ms then overwriting with different bytes.
|
|
// 5ms is well above the Linux mtime resolution (typically nanoseconds) and
|
|
// below any Windows cache coherency window. Using a longer sleep pads against
|
|
// coarse filesystem mtime granularity without slowing the suite.
|
|
const start = Date.now();
|
|
while (Date.now() - start < 5) {
|
|
/* spin */
|
|
}
|
|
writeFileSync(sourceFile, "different-bytes-longer-than-before", "utf-8");
|
|
const after = computeCacheKey(base(sourceFile));
|
|
expect(after).not.toBe(before);
|
|
});
|
|
|
|
it("readKeyStat returns null for a missing source (callers skip the cache)", () => {
|
|
// Previously readKeyStat returned a `{mtimeMs: 0, size: 0}` sentinel for
|
|
// missing files; two unrelated missing paths then shared the same cache
|
|
// key tuple and polluted the cache. The contract now returns null so
|
|
// callers can explicitly skip the cache path and let the extractor
|
|
// surface the real file-not-found error.
|
|
const missing = join(tmpRoot, "does-not-exist.mp4");
|
|
expect(readKeyStat(missing)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("cacheEntryDirName", () => {
|
|
it("prefixes with the schema and truncates to 16 hex chars", () => {
|
|
const full = "a".repeat(64);
|
|
expect(cacheEntryDirName(full)).toBe(`${SCHEMA_PREFIX}${"a".repeat(16)}`);
|
|
});
|
|
});
|
|
|
|
describe("lookupCacheEntry / markCacheEntryComplete", () => {
|
|
let tmpRoot: string;
|
|
let sourceFile: string;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
|
sourceFile = join(tmpRoot, "clip.mp4");
|
|
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
const base = (videoPath: string): CacheKeyInput => keyFor(videoPath);
|
|
|
|
it("misses on an empty cache root", () => {
|
|
const lookup = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
expect(lookup.hit).toBe(false);
|
|
expect(lookup.entry.dir.startsWith(tmpRoot)).toBe(true);
|
|
});
|
|
|
|
it("hits after ensureCacheEntryDir + markCacheEntryComplete", () => {
|
|
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
ensureCacheEntryDir(first.entry);
|
|
markCacheEntryComplete(first.entry);
|
|
|
|
const second = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
expect(second.hit).toBe(true);
|
|
expect(second.entry.dir).toBe(first.entry.dir);
|
|
});
|
|
|
|
it("treats an in-progress dir without the sentinel as a miss", () => {
|
|
const lookup = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
ensureCacheEntryDir(lookup.entry);
|
|
// Simulate abandoned extraction — frames written but sentinel never marked.
|
|
writeFileSync(join(lookup.entry.dir, "frame_00001.jpg"), "x", "utf-8");
|
|
const again = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
expect(again.hit).toBe(false);
|
|
});
|
|
|
|
it("places entries under the cache root, not the source parent", () => {
|
|
const subroot = join(tmpRoot, "cache-root");
|
|
mkdirSync(subroot, { recursive: true });
|
|
const lookup = lookupCacheEntry(subroot, base(sourceFile));
|
|
expect(lookup.entry.dir.startsWith(subroot)).toBe(true);
|
|
});
|
|
|
|
it("uses the same directory for identical inputs across lookups", () => {
|
|
const a = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
const b = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
expect(a.entry.dir).toBe(b.entry.dir);
|
|
});
|
|
});
|