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
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export {
probeSourceForCacheKey,
resolveCacheEntryPaths,
CACHE_SENTINEL_FILENAME,
FRAME_FILENAME_PREFIX,
type ExtractionCacheKeyInputs,
type CacheEntryPaths,
type CacheHit,
Expand Down
15 changes: 13 additions & 2 deletions packages/engine/src/services/extractionCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ const CACHE_SCHEMA_VERSION = 2;
*/
export const CACHE_SENTINEL_FILENAME = ".hf-complete";

/**
* Filename prefix shared by every extracted frame on disk. Used by ffmpeg's
* `-y outputDir/${FRAME_FILENAME_PREFIX}%05d.${format}` and by the cache
* lookup's directory filter — keeping them in sync via a single export
* prevents a one-sided rename from silently producing zero cache hits.
*/
export const FRAME_FILENAME_PREFIX = "frame_";

export interface ExtractionCacheKeyInputs {
/** Resolved absolute path to the source video file. */
sourcePath: string;
Expand Down Expand Up @@ -160,7 +168,9 @@ export function lookupCacheEntry(

const suffix = `.${format}`;
const framePaths = new Map<number, string>();
const matching = entries.filter((f) => f.startsWith("frame_") && f.endsWith(suffix)).sort();
const matching = entries
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(suffix))
.sort();
matching.forEach((file, index) => {
framePaths.set(index, join(dir, file));
});
Expand All @@ -184,6 +194,7 @@ export function markCacheEntryComplete(cacheRoot: string, key: string): void {
*/
export function ensureCacheEntryDir(cacheRoot: string, key: string): string {
const { dir } = resolveCacheEntryPaths(cacheRoot, key);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
// mkdirSync({recursive:true}) is idempotent — no existsSync precheck needed.
mkdirSync(dir, { recursive: true });
return dir;
}
209 changes: 110 additions & 99 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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,
computeExtractionCacheKey,
ensureCacheEntryDir,
lookupCacheEntry,
Expand Down Expand Up @@ -242,7 +243,7 @@ export async function extractVideoFramesRange(
if (!existsSync(videoOutputDir)) mkdirSync(videoOutputDir, { recursive: true });

const metadata = await extractVideoMetadata(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
Expand Down Expand Up @@ -332,7 +333,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));
Expand Down Expand Up @@ -508,7 +509,19 @@ export async function extractAllVideoFrames(

// Phase 1: Resolve paths and download remote videos
const resolveStart = Date.now();
const resolvedVideos: Array<{ video: VideoElement; videoPath: string }> = [];
const resolvedVideos: Array<{
video: VideoElement;
videoPath: string;
/**
* Full ffprobe metadata for the original (pre-preflight) source. Set
* during Phase 2 and reused by Phase 3 cache hits so we don't re-probe
* every input. For inputs that go through the HDR or VFR preflight
* (which mutate `videoPath`), this metadata becomes stale — but
* preflighted inputs always cache-miss anyway, so the staleness is
* inert there.
*/
sourceMetadata?: VideoMetadata;
}> = [];
for (const video of videos) {
if (signal?.aborted) break;
try {
Expand Down Expand Up @@ -536,78 +549,76 @@ export async function extractAllVideoFrames(
}
phaseBreakdown.resolveMs = Date.now() - resolveStart;

// Phase 2: Probe color spaces and normalize if mixed HDR/SDR
// Phase 2: Probe color spaces and normalize if mixed HDR/SDR. The full
// metadata is stashed onto each resolvedVideo so later phases (VFR check,
// Phase 3 cache hits) can skip a redundant ffprobe per input.
const probeStart = Date.now();
const videoProbes = await Promise.all(
resolvedVideos.map(async ({ videoPath }) => {
const metadata = await extractVideoMetadata(videoPath);
return { colorSpace: metadata.colorSpace, durationSeconds: metadata.durationSeconds };
await Promise.all(
resolvedVideos.map(async (entry) => {
entry.sourceMetadata = await extractVideoMetadata(entry.videoPath);
}),
);
phaseBreakdown.probeMs += Date.now() - probeStart;

const hasAnyHdr = videoProbes.some((p) => isHdrColorSpaceUtil(p.colorSpace));
const hasAnyHdr = resolvedVideos.some((e) =>
isHdrColorSpaceUtil(e.sourceMetadata?.colorSpace ?? null),
);
if (hasAnyHdr) {
const convertDir = join(options.outputDir, "_hdr_normalized");
mkdirSync(convertDir, { recursive: true });

for (let i = 0; i < resolvedVideos.length; i++) {
for (const entry of resolvedVideos) {
if (signal?.aborted) break;
const probe = videoProbes[i];
const cs = probe?.colorSpace ?? null;
if (!isHdrColorSpaceUtil(cs)) {
// SDR video in a mixed timeline — convert to HDR color space
const entry = resolvedVideos[i];
if (!entry) continue;

// Segment-scope the re-encode to the used window. For an explicit
// [start, end] pair this is end-start; for unbounded clips fall back
// to the source's natural duration minus mediaStart (same fallback
// used by Phase 3 and Phase 2b).
let segDuration = entry.video.end - entry.video.start;
if (!Number.isFinite(segDuration) || segDuration <= 0) {
const sourceDuration = probe?.durationSeconds ?? 0;
const sourceRemaining = sourceDuration - entry.video.mediaStart;
segDuration = sourceRemaining > 0 ? sourceRemaining : sourceDuration;
}
const cs = entry.sourceMetadata?.colorSpace ?? null;
if (isHdrColorSpaceUtil(cs)) continue;
// SDR video in a mixed timeline — convert to HDR color space.
// Segment-scope the re-encode to the used window. For an explicit
// [start, end] pair this is end-start; for unbounded clips fall back
// to the source's natural duration minus mediaStart (same fallback
// used by Phase 3 and Phase 2b).
let segDuration = entry.video.end - entry.video.start;
if (!Number.isFinite(segDuration) || segDuration <= 0) {
const sourceDuration = entry.sourceMetadata?.durationSeconds ?? 0;
const sourceRemaining = sourceDuration - entry.video.mediaStart;
segDuration = sourceRemaining > 0 ? sourceRemaining : sourceDuration;
}

const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
const hdrStart = Date.now();
try {
await convertSdrToHdr(
entry.videoPath,
convertedPath,
entry.video.mediaStart,
segDuration,
signal,
config,
);
entry.videoPath = convertedPath;
// Segment-scoped re-encode starts the new file at t=0, so
// downstream phases (VFR preflight + Phase 3 extraction) must seek
// from 0, not the original mediaStart. Shallow-copy to avoid
// mutating the caller's VideoElement.
entry.video = { ...entry.video, mediaStart: 0 };
phaseBreakdown.hdrPreflightCount += 1;
} catch (err) {
errors.push({
videoId: entry.video.id,
error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
});
} finally {
phaseBreakdown.hdrPreflightMs += Date.now() - hdrStart;
}
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
const hdrStart = Date.now();
try {
await convertSdrToHdr(
entry.videoPath,
convertedPath,
entry.video.mediaStart,
segDuration,
signal,
config,
);
entry.videoPath = convertedPath;
// Segment-scoped re-encode starts the new file at t=0, so
// downstream phases (VFR preflight + Phase 3 extraction) must seek
// from 0, not the original mediaStart. Shallow-copy to avoid
// mutating the caller's VideoElement.
entry.video = { ...entry.video, mediaStart: 0 };
phaseBreakdown.hdrPreflightCount += 1;
} catch (err) {
errors.push({
videoId: entry.video.id,
error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
});
} finally {
phaseBreakdown.hdrPreflightMs += Date.now() - hdrStart;
}
}
}

// Phase 2b: Re-encode VFR inputs to CFR so the fps filter in Phase 3 produces
// the expected frame count. Only the used segment is transcoded.
// the expected frame count. Only the used segment is transcoded. We re-probe
// here (rather than reuse Phase 2's metadata) because HDR preflight may have
// rewritten the file with a different fps/timing structure.
const vfrNormDir = join(options.outputDir, "_vfr_normalized");
for (let i = 0; i < resolvedVideos.length; i++) {
for (const entry of resolvedVideos) {
if (signal?.aborted) break;
const entry = resolvedVideos[i];
if (!entry) continue;
const vfrProbeStart = Date.now();
const metadata = await extractVideoMetadata(entry.videoPath);
phaseBreakdown.probeMs += Date.now() - vfrProbeStart;
Expand Down Expand Up @@ -665,7 +676,8 @@ export async function extractAllVideoFrames(
const cacheRoot = config?.extractCacheDir;
const extractFormat = options.format ?? "jpg";
const results = await Promise.all(
resolvedVideos.map(async ({ video, videoPath }) => {
resolvedVideos.map(async (entry) => {
const { video, videoPath, sourceMetadata } = entry;
if (signal?.aborted) {
throw new Error("Video frame extraction cancelled");
}
Expand All @@ -675,49 +687,62 @@ export async function extractAllVideoFrames(
// 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 extractVideoMetadata(videoPath);
const metadata = sourceMetadata ?? (await extractVideoMetadata(videoPath));
const sourceDuration = metadata.durationSeconds - video.mediaStart;
videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
video.end = video.start + videoDuration;
}

// ── Cache lookup ────────────────────────────────────────────────
let cacheEntryDir: string | null = null;
// Compute the key once and reuse for the post-extract sentinel
// write. cacheKey is null when caching is disabled or the source
// can't be stat'd (e.g. fresh HTTP download bypasses cache by
// design — see extractionCache.ts).
let cacheKey: string | null = null;
if (cacheRoot) {
const sourceStat = probeSourceForCacheKey(videoPath);
if (sourceStat) {
const key = computeExtractionCacheKey({
cacheKey = computeExtractionCacheKey({
...sourceStat,
mediaStart: video.mediaStart,
duration: videoDuration,
fps: options.fps,
format: extractFormat,
});
const hit = lookupCacheEntry(cacheRoot, key, extractFormat);
if (hit) {
phaseBreakdown.cacheHits += 1;
const metadata = await extractVideoMetadata(videoPath);
return {
result: {
videoId: video.id,
srcPath: videoPath,
outputDir: hit.dir,
framePattern: `frame_%05d.${extractFormat}`,
fps: options.fps,
totalFrames: hit.totalFrames,
metadata,
framePaths: hit.framePaths,
ownedByLookup: false,
} satisfies ExtractedFrames,
};
}
// Cache miss — extract into the cache entry dir so the next
// render with the same inputs is a hit.
cacheEntryDir = ensureCacheEntryDir(cacheRoot, key);
phaseBreakdown.cacheMisses += 1;
}
}

let cacheEntryDir: string | null = null;
if (cacheRoot && cacheKey) {
const hit = lookupCacheEntry(cacheRoot, cacheKey, extractFormat);
if (hit) {
phaseBreakdown.cacheHits += 1;
// Reuse the metadata Phase 2 already probed instead of paying
// for a second ffprobe just to populate ExtractedFrames.
// sourceMetadata is current for cache-hit inputs because the
// cache only matches when neither HDR nor VFR preflight ran
// (those mutate videoPath, which changes the cache key).
const metadata = sourceMetadata ?? (await extractVideoMetadata(videoPath));
return {
result: {
videoId: video.id,
srcPath: videoPath,
outputDir: hit.dir,
framePattern: `${FRAME_FILENAME_PREFIX}%05d.${extractFormat}`,
fps: options.fps,
totalFrames: hit.totalFrames,
metadata,
framePaths: hit.framePaths,
ownedByLookup: false,
} satisfies ExtractedFrames,
};
}
// Cache miss — extract into the cache entry dir so the next
// render with the same inputs is a hit.
cacheEntryDir = ensureCacheEntryDir(cacheRoot, cacheKey);
phaseBreakdown.cacheMisses += 1;
}

const result = await extractVideoFramesRange(
videoPath,
video.id,
Expand All @@ -729,22 +754,8 @@ export async function extractAllVideoFrames(
cacheEntryDir ?? undefined,
);

if (cacheRoot && cacheEntryDir) {
// Reuse the cache-derived key by re-deriving it from the source
// stat so we write the sentinel next to the frames ffmpeg just
// produced. (The dir basename IS the key, but derive it cleanly
// rather than parsing a path.)
const sourceStat = probeSourceForCacheKey(videoPath);
if (sourceStat) {
const key = computeExtractionCacheKey({
...sourceStat,
mediaStart: video.mediaStart,
duration: videoDuration,
fps: options.fps,
format: extractFormat,
});
markCacheEntryComplete(cacheRoot, key);
}
if (cacheRoot && cacheKey && cacheEntryDir) {
markCacheEntryComplete(cacheRoot, cacheKey);
// Mark the ExtractedFrames as cache-owned so FrameLookupTable
// doesn't rm it at end-of-render.
return { result: { ...result, ownedByLookup: false } };
Expand Down
13 changes: 8 additions & 5 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,6 @@ export async function executeRenderJob(
// only sequential renders populate it (and only those render types
// contribute to `perfSummary.injectorStats`).
const injectorCacheStats: InjectorCacheStats = createEmptyInjectorCacheStats();
let sequentialInjectorUsed = false;

// Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR).
// This is needed to identify which videos are natively HDR vs converted-SDR
Expand Down Expand Up @@ -1315,7 +1314,6 @@ export async function executeRenderJob(
// throws "video metadata not ready" even though we never asked the
// browser to decode the video.
const domInjector = createVideoFrameInjector(frameLookup, cfg, injectorCacheStats);
if (domInjector) sequentialInjectorUsed = true;
const domSession = await createCaptureSession(
fileServer.url,
framesDir,
Expand Down Expand Up @@ -2101,7 +2099,6 @@ export async function executeRenderJob(
// Sequential capture → streaming encode

const videoInjector = createVideoFrameInjector(frameLookup, cfg, injectorCacheStats);
if (videoInjector) sequentialInjectorUsed = true;
const session =
probeSession ??
(await createCaptureSession(
Expand Down Expand Up @@ -2204,7 +2201,6 @@ export async function executeRenderJob(
// Sequential capture

const videoInjector = createVideoFrameInjector(frameLookup, cfg, injectorCacheStats);
if (videoInjector) sequentialInjectorUsed = true;
const session =
probeSession ??
(await createCaptureSession(
Expand Down Expand Up @@ -2366,7 +2362,14 @@ export async function executeRenderJob(
captureAvgMs:
totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : undefined,
videoExtractBreakdown: extractionResult?.phaseBreakdown,
injectorStats: sequentialInjectorUsed ? { ...injectorCacheStats } : undefined,
// Stats are populated only by sequential-capture injectors (parallel
// workers run in separate processes). When all reads were 0 the
// injector either wasn't used or saw no traffic — omit the field.
injectorStats:
injectorCacheStats.hits + injectorCacheStats.misses + injectorCacheStats.inFlightCoalesced >
0
? { ...injectorCacheStats }
: undefined,
tmpPeakBytes,
};
job.perfSummary = perfSummary;
Expand Down
Loading