Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/engine/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ export interface EngineConfig {
// ── Media ────────────────────────────────────────────────────────────
audioGain: number;
frameDataUriCacheLimit: number;
/**
* Directory for the extraction cache. When set, `extractAllVideoFrames`
* reuses previously-extracted frames for `(source, window, fps, format)`
* pairs it has seen before. Cache entries are content-addressed by the
* source file's path + mtime + size, and each entry completes with a
* sentinel file so partial/aborted extractions aren't served as hits.
*
* Disabled by default — users opt in, since the cache grows unbounded
* until eviction lands in a follow-up. HTTP-sourced inputs bypass the
* cache (each download resolves to a fresh tmp path).
*/
extractCacheDir?: string;

// ── Timeouts ─────────────────────────────────────────────────────────
playerReadyTimeout: number;
Expand Down Expand Up @@ -203,6 +215,8 @@ export function resolveConfig(overrides?: Partial<EngineConfig>): EngineConfig {

verifyRuntime: env("PRODUCER_VERIFY_HYPERFRAME_RUNTIME") !== "false",
runtimeManifestPath: env("PRODUCER_HYPERFRAME_MANIFEST_PATH"),

extractCacheDir: env("HYPERFRAMES_EXTRACT_CACHE_DIR"),
};

// Remove undefined values so they don't override defaults
Expand Down
13 changes: 13 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ export {
type InjectorCacheStats,
} from "./services/videoFrameInjector.js";

export {
computeExtractionCacheKey,
lookupCacheEntry,
markCacheEntryComplete,
ensureCacheEntryDir,
probeSourceForCacheKey,
resolveCacheEntryPaths,
CACHE_SENTINEL_FILENAME,
type ExtractionCacheKeyInputs,
type CacheEntryPaths,
type CacheHit,
} from "./services/extractionCache.js";

export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js";
export type { AudioElement, AudioTrack, MixResult } from "./services/audioMixer.types.js";

Expand Down
147 changes: 147 additions & 0 deletions packages/engine/src/services/extractionCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
CACHE_SENTINEL_FILENAME,
computeExtractionCacheKey,
ensureCacheEntryDir,
lookupCacheEntry,
markCacheEntryComplete,
probeSourceForCacheKey,
resolveCacheEntryPaths,
} from "./extractionCache.js";

// These tests cover the content-addressable cache that the architecture
// review calls out as the biggest wall-clock win for iteration workflows:
// on a second render of the same composition, every input should be served
// from the cache instead of being re-extracted by ffmpeg.

describe("computeExtractionCacheKey", () => {
const base = {
sourcePath: "/videos/clip.mp4",
sourceMtimeMs: 1_700_000_000_000,
sourceSize: 5_242_880,
mediaStart: 0,
duration: 4,
fps: 30,
format: "jpg" as const,
};

it("is deterministic for the same inputs", () => {
const a = computeExtractionCacheKey(base);
const b = computeExtractionCacheKey(base);
expect(a).toBe(b);
});

it("prefixes the key with the schema version so a future on-disk format change cannot collide", () => {
const key = computeExtractionCacheKey(base);
expect(key.startsWith("v1-")).toBe(true);
});

it.each([
["sourcePath", { sourcePath: "/videos/other.mp4" }],
["sourceMtimeMs", { sourceMtimeMs: 1_700_000_000_001 }],
["sourceSize", { sourceSize: 5_242_881 }],
["mediaStart", { mediaStart: 0.5 }],
["duration", { duration: 4.25 }],
["fps", { fps: 60 }],
["format", { format: "png" as const }],
])("differs when %s changes", (_field, override) => {
const base_key = computeExtractionCacheKey(base);
const changed = computeExtractionCacheKey({ ...base, ...override });
expect(changed).not.toBe(base_key);
});

it("treats float timing values to 6 decimal places (stable keys across equivalent floats)", () => {
const a = computeExtractionCacheKey({ ...base, mediaStart: 1.5 });
const b = computeExtractionCacheKey({ ...base, mediaStart: 1.5000001 });
expect(a).toBe(b);
});
});

describe("probeSourceForCacheKey", () => {
const DIR = mkdtempSync(join(tmpdir(), "hf-extcache-probe-"));
const VIDEO = join(DIR, "video.mp4");

beforeEach(() => {
writeFileSync(VIDEO, Buffer.from("fake video bytes"));
});

afterEach(() => {
// Leave DIR in place — afterAll-style cleanup happens at test end via
// the mkdtemp dir being removed on process exit. Explicit rm here would
// break subsequent beforeEach writes to VIDEO.
});

it("returns path, mtime, and size for existing files", () => {
const probe = probeSourceForCacheKey(VIDEO);
expect(probe).not.toBeNull();
expect(probe?.sourcePath).toBe(VIDEO);
expect(probe?.sourceSize).toBe(statSync(VIDEO).size);
expect(probe?.sourceMtimeMs).toBeGreaterThan(0);
});

it("returns null for a missing file", () => {
expect(probeSourceForCacheKey(join(DIR, "does-not-exist.mp4"))).toBeNull();
});
});

describe("lookupCacheEntry / markCacheEntryComplete", () => {
let ROOT: string;

beforeEach(() => {
ROOT = mkdtempSync(join(tmpdir(), "hf-extcache-lookup-"));
});

afterEach(() => {
rmSync(ROOT, { recursive: true, force: true });
});

it("returns null when the entry dir does not exist", () => {
expect(lookupCacheEntry(ROOT, "v1-abc", "jpg")).toBeNull();
});

it("returns null when the entry dir exists but has no sentinel (partial extraction)", () => {
const dir = ensureCacheEntryDir(ROOT, "v1-abc");
// Write a frame but NOT the sentinel — simulates an aborted extraction.
writeFileSync(join(dir, "frame_00001.jpg"), Buffer.from([0xff, 0xd8]));
expect(lookupCacheEntry(ROOT, "v1-abc", "jpg")).toBeNull();
});

it("returns null when the sentinel exists but no frames match the requested format", () => {
const dir = ensureCacheEntryDir(ROOT, "v1-abc");
writeFileSync(join(dir, "frame_00001.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
markCacheEntryComplete(ROOT, "v1-abc");
// Cache was built for PNG but we're asking for JPG — no files match,
// treat as a miss so the caller extracts fresh.
expect(lookupCacheEntry(ROOT, "v1-abc", "jpg")).toBeNull();
});

it("returns a hit with frame paths when sentinel + matching frames are present", () => {
const dir = ensureCacheEntryDir(ROOT, "v1-abc");
for (let i = 1; i <= 3; i++) {
writeFileSync(
join(dir, `frame_${String(i).padStart(5, "0")}.jpg`),
Buffer.from([0xff, 0xd8, i]),
);
}
markCacheEntryComplete(ROOT, "v1-abc");

const hit = lookupCacheEntry(ROOT, "v1-abc", "jpg");
expect(hit).not.toBeNull();
expect(hit?.totalFrames).toBe(3);
expect(hit?.framePaths.size).toBe(3);
// Frame indices are 0-based in the map (matching ExtractedFrames semantics).
expect(hit?.framePaths.get(0)).toBe(join(dir, "frame_00001.jpg"));
expect(hit?.framePaths.get(2)).toBe(join(dir, "frame_00003.jpg"));
});

it("writes the sentinel at the expected path", () => {
ensureCacheEntryDir(ROOT, "v1-xyz");
markCacheEntryComplete(ROOT, "v1-xyz");
const { sentinel } = resolveCacheEntryPaths(ROOT, "v1-xyz");
expect(existsSync(sentinel)).toBe(true);
expect(sentinel.endsWith(CACHE_SENTINEL_FILENAME)).toBe(true);
});
});
186 changes: 186 additions & 0 deletions packages/engine/src/services/extractionCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Video Frame Extraction Cache
*
* Content-addressed cache for pre-extracted frames. When enabled, the
* extractor checks for a completed cache entry before running FFmpeg, and
* writes frames into the cache directory on miss. Purpose: skip Phase 3
* entirely on iteration workflows where the same `(source, window, fps,
* format)` pair recurs across renders.
*
* Keying: the cache key is a SHA-256 over a stable string of
* path | mtime-ms | size | mediaStart | duration | fps | format
* plus a schema version. File-content hashing is deliberately avoided
* because typical video sources are hundreds of MB and hashing them on
* every render would defeat the purpose. mtime+size is a good proxy for
* "the same file on disk"; users who mutate a file in-place at the exact
* same size+mtime are expected to bump the cache or disable it.
*
* Completeness: each entry directory gets a `.hf-complete` sentinel file
* written at the end of a successful extraction. Cache hits require the
* sentinel — partial writes from a killed/aborted render are ignored and
* overwritten on the next extraction.
*
* Concurrency: two renders that miss the same key will both extract into
* the same cache dir. ffmpeg overwrites its own files, both touch the
* sentinel, last-writer-wins on the sentinel timestamp. Correctness is
* fine because the key is content-addressed; the only cost is the
* duplicated work, which is acceptable for v1.
*
* Eviction: none yet. The cache grows until the user clears it. A future
* PR adds size-capped LRU eviction.
*/

import { createHash } from "crypto";
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
import { join } from "path";

/**
* Schema version embedded in every cache key. Bump whenever the on-disk
* format of extracted frames changes in a way that breaks older entries
* (e.g. the upcoming WebP-unified-format PR changes the frame extension
* and MIME handling, so it will bump this to 2).
*/
const CACHE_SCHEMA_VERSION = 1;

/**
* Sentinel filename written inside each completed cache entry directory.
* Absence means the entry is partial or never finished — treated as a miss.
*/
export const CACHE_SENTINEL_FILENAME = ".hf-complete";

export interface ExtractionCacheKeyInputs {
/** Resolved absolute path to the source video file. */
sourcePath: string;
/** Source file mtime in ms (from statSync). */
sourceMtimeMs: number;
/** Source file size in bytes. */
sourceSize: number;
/** Start of the used window in source-media time (seconds). */
mediaStart: number;
/** Duration of the used window (seconds). */
duration: number;
/** Target fps for extracted frames. */
fps: number;
/** Extracted frame format ("jpg" or "png"). */
format: "jpg" | "png";
}

/**
* Compute a deterministic cache key for a `(source, window, fps, format)`
* tuple. The key encodes the schema version as a prefix so a future change
* to the on-disk format invalidates old entries without collision.
*/
export function computeExtractionCacheKey(inputs: ExtractionCacheKeyInputs): string {
const parts = [
`v${CACHE_SCHEMA_VERSION}`,
inputs.sourcePath,
String(Math.floor(inputs.sourceMtimeMs)),
String(inputs.sourceSize),
inputs.mediaStart.toFixed(6),
inputs.duration.toFixed(6),
String(inputs.fps),
inputs.format,
];
const hash = createHash("sha256").update(parts.join("|")).digest("hex");
// 32 hex chars (128 bits) is ample for a local cache and keeps directory
// names short enough for every common filesystem.
return `v${CACHE_SCHEMA_VERSION}-${hash.slice(0, 32)}`;
}

/**
* Stat a source file and derive the inputs needed to compute a cache key.
* Returns null if the file is missing or unreadable — callers treat that
* as "no cache" and proceed without it.
*/
export function probeSourceForCacheKey(
sourcePath: string,
): Pick<ExtractionCacheKeyInputs, "sourcePath" | "sourceMtimeMs" | "sourceSize"> | null {
try {
const st = statSync(sourcePath);
if (!st.isFile()) return null;
return {
sourcePath,
sourceMtimeMs: st.mtimeMs,
sourceSize: st.size,
};
} catch {
return null;
}
}

export interface CacheEntryPaths {
/** Absolute path to the cache entry directory. Created lazily by the caller. */
dir: string;
/** Absolute path to the sentinel file written on successful extraction. */
sentinel: string;
}

/**
* Resolve the on-disk paths for a cache entry given a root cache dir and a
* computed key. Does not create the directory — that is the caller's job,
* typically on miss before handing the path to ffmpeg.
*/
export function resolveCacheEntryPaths(cacheRoot: string, key: string): CacheEntryPaths {
const dir = join(cacheRoot, key);
return { dir, sentinel: join(dir, CACHE_SENTINEL_FILENAME) };
}

export interface CacheHit {
/** Cache entry directory holding `frame_00001.jpg` (or .png) + sentinel. */
dir: string;
/** Map of 0-based frame index → absolute frame path. */
framePaths: Map<number, string>;
/** Number of frames discovered. Matches `framePaths.size`. */
totalFrames: number;
}

/**
* Look up a cache entry and — if complete — return the frame paths it
* contains. Returns null for misses (missing dir, missing sentinel, no
* matching frames). Callers should treat a null return as "extract into
* this dir and then call `markCacheEntryComplete` on success."
*/
export function lookupCacheEntry(
cacheRoot: string,
key: string,
format: "jpg" | "png",
): CacheHit | null {
const { dir, sentinel } = resolveCacheEntryPaths(cacheRoot, key);
if (!existsSync(sentinel)) return null;

let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return null;
}

const suffix = `.${format}`;
const framePaths = new Map<number, string>();
const matching = entries.filter((f) => f.startsWith("frame_") && f.endsWith(suffix)).sort();
matching.forEach((file, index) => {
framePaths.set(index, join(dir, file));
});

if (framePaths.size === 0) return null;
return { dir, framePaths, totalFrames: framePaths.size };
}

/**
* Mark a cache entry complete by writing the sentinel file. Called only
* after ffmpeg has finished writing every frame into the entry directory.
*/
export function markCacheEntryComplete(cacheRoot: string, key: string): void {
const { sentinel } = resolveCacheEntryPaths(cacheRoot, key);
writeFileSync(sentinel, "");
}

/**
* Ensure the cache root and a specific entry directory exist. Returns the
* absolute path of the entry directory.
*/
export function ensureCacheEntryDir(cacheRoot: string, key: string): string {
const { dir } = resolveCacheEntryPaths(cacheRoot, key);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
Loading
Loading