perf(engine): one-pass VFR extraction with -fps_mode cfr (#1899)

* perf(engine): write PNG frames at compression_level 1

Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.

* perf(engine): one-pass VFR extraction with -fps_mode cfr

VFR sources (screen recordings, phone videos) were re-encoded to CFR
with libx264 and then extracted in a second ffmpeg pass. Extraction now
runs a single pass with -fps_mode cfr -r <fps>. Same frame counts on
the VFR regression fixtures (120/120 mid-seek, 297-303 full file), one
less x264 generation of quality loss, ~3.4x faster on VFR inputs.
convertVfrToCfr and the _vfr_normalized intermediate are deleted.

The full-VFR test's byte-identical duplicate-frame cap is retired with
cause: the fixture has no source frames for 40% of its timeline, so
held frames are correct; the two-pass path only scored under it because
x264 encoder noise made frozen frames hash differently. The freeze
regression (missing frames) stays pinned by the frame-count windows.

* docs(engine): pin vfrPreflightMs definition change after one-pass VFR

vfrPreflightMs used to time a per-source VFR-to-CFR re-encode; it now
times only the cached classification probe and collapses to ~0. Call
that out on ExtractionPhaseBreakdown so dashboards keyed on the old
threshold semantics migrate to vfrPreflightCount / extractMs.

* fix(engine): bump extraction cache schema to v3 for one-pass VFR frames

One-pass VFR extraction changes frame CONTENTS for VFR sources while
the cache key tuple (path, mtime, size, trim, fps, format) is
unchanged, so warm v2 entries holding two-pass frames would keep being
served across the deploy boundary. Bumping the schema prefix makes v2
entries inert; affected sources re-extract once.
This commit is contained in:
Miguel Ángel
2026-07-03 13:39:15 -07:00
committed by GitHub
parent 557a270271
commit 7860583341
4 changed files with 47 additions and 143 deletions
@@ -33,7 +33,7 @@ const keyFor = (videoPath: string, overrides: Partial<CacheKeyInput> = {}): Cach
describe("extractionCache constants", () => { describe("extractionCache constants", () => {
it("exposes the v2 schema prefix", () => { it("exposes the v2 schema prefix", () => {
expect(SCHEMA_PREFIX).toBe("hfcache-v2-"); expect(SCHEMA_PREFIX).toBe("hfcache-v3-");
}); });
it("exposes the frame filename prefix shared with the extractor", () => { it("exposes the frame filename prefix shared with the extractor", () => {
@@ -39,8 +39,14 @@ export const FRAME_FILENAME_PREFIX = "frame_";
/** Sentinel filename written after a cache entry is fully populated. */ /** Sentinel filename written after a cache entry is fully populated. */
export const COMPLETE_SENTINEL = ".hf-complete"; export const COMPLETE_SENTINEL = ".hf-complete";
/** Current schema version. Bump when cache-entry layout changes. */ /**
export const SCHEMA_PREFIX = "hfcache-v2-"; * Current schema version. Bump when the cache-contents invariant changes.
* v2 -> v3: one-pass VFR extraction (-fps_mode cfr) replaces the two-pass
* VFR-to-CFR re-encode, changing frame contents for VFR sources under
* identical key tuples. Without the bump, warm v2 entries (two-pass frames)
* would keep being served across the deploy boundary.
*/
export const SCHEMA_PREFIX = "hfcache-v3-";
/** Truncated hex chars of SHA-256 used for the entry directory name. */ /** Truncated hex chars of SHA-256 used for the entry directory name. */
const KEY_HEX_CHARS = 16; const KEY_HEX_CHARS = 16;
@@ -1,14 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
@@ -700,8 +691,9 @@ describe.skipIf(!HAS_FFMPEG)("video frame extraction format", () => {
// e.g. a 4-second segment at 30fps would produce ~90 frames instead of 120. // e.g. a 4-second segment at 30fps would produce ~90 frames instead of 120.
// FrameLookupTable.getFrameAtTime then returns null for out-of-range indices // FrameLookupTable.getFrameAtTime then returns null for out-of-range indices
// and the compositor holds the last valid frame, which the user perceives as // and the compositor holds the last valid frame, which the user perceives as
// the video freezing. extractAllVideoFrames normalizes VFR sources to CFR // the video freezing. extractAllVideoFrames now routes VFR sources through
// before extraction to fix this. // FFmpeg's one-pass `-fps_mode cfr -r` extraction path to fix this without a
// separate normalization encode.
describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => { describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-vfr-test-")); const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-vfr-test-"));
const VFR_FIXTURE = join(FIXTURE_DIR, "vfr_screen.mp4"); const VFR_FIXTURE = join(FIXTURE_DIR, "vfr_screen.mp4");
@@ -779,23 +771,23 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
expect(result.extracted).toHaveLength(1); expect(result.extracted).toHaveLength(1);
const frames = readdirSync(join(outputDir, "v1")).filter((f) => f.endsWith(".jpg")); const frames = readdirSync(join(outputDir, "v1")).filter((f) => f.endsWith(".jpg"));
// Pre-fix behavior produced ~90 frames (a 25% shortfall). // Pre-fix behavior produced ~90 frames (a 25% shortfall).
// ±3 tolerance: FFmpeg's VFR→CFR normalization yields slightly different // ±3 tolerance: FFmpeg's one-pass VFR→CFR extraction yields slightly
// frame counts across versions (timestamp rounding in the fps filter). // different frame counts across versions (timestamp rounding).
expect(frames.length).toBeGreaterThanOrEqual(117); expect(frames.length).toBeGreaterThanOrEqual(117);
expect(frames.length).toBeLessThanOrEqual(123); expect(frames.length).toBeLessThanOrEqual(123);
expect(result.phaseBreakdown).toBeDefined(); expect(result.phaseBreakdown).toBeDefined();
expect(result.phaseBreakdown.extractMs).toBeGreaterThan(0); expect(result.phaseBreakdown.extractMs).toBeGreaterThan(0);
expect(result.phaseBreakdown.vfrPreflightCount).toBe(1); expect(result.phaseBreakdown.vfrPreflightCount).toBe(1);
expect(result.phaseBreakdown.vfrPreflightMs).toBeGreaterThan(0); expect(result.phaseBreakdown.vfrPreflightMs).toBeGreaterThanOrEqual(0);
}, 60_000); }, 60_000);
it("reuses extracted frames on a warm cache hit", async () => { it("reuses extracted frames on a warm cache hit", async () => {
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-")); const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
const SRC = join(FIXTURE_DIR, "cache-src.mp4"); const SRC = join(FIXTURE_DIR, "cache-src.mp4");
// Synthesize a clean CFR SDR clip — bypasses VFR preflight so the cache // Synthesize a clean CFR SDR clip — keeps VFR preflight count at zero so
// key is stable across the two runs. // the cache key is stable across the two runs.
const synth = await runFfmpeg([ const synth = await runFfmpeg([
"-y", "-y",
"-hide_banner", "-hide_banner",
@@ -1012,13 +1004,10 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
expect(convertedMeta.durationSeconds).toBeLessThan(2.5); expect(convertedMeta.durationSeconds).toBeLessThan(2.5);
}, 60_000); }, 60_000);
// Asserts both frame-count correctness and that we don't emit long runs of // Asserts frame-count correctness for a full VFR file. One-pass CFR image
// byte-identical "duplicate" frames — the user-visible "frozen screen // extraction may repeat held source frames across timestamp gaps; the freeze
// recording" symptom. Pre-fix duplicate rate on this fixture is ~38% // regression is missing frames, which leaves late timeline lookups null.
// (116/300); on the actual reporter's ScreenCaptureKit clip, 1844% across it("produces the full frame count on the full VFR file", async () => {
// segments. <10% threshold leaves margin across ffmpeg versions without
// letting a regression slip through.
it("produces the full frame count and no duplicate-frame runs on the full VFR file", async () => {
const outputDir = join(FIXTURE_DIR, "out-full"); const outputDir = join(FIXTURE_DIR, "out-full");
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
@@ -1042,21 +1031,10 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
const frames = readdirSync(frameDir) const frames = readdirSync(frameDir)
.filter((f) => f.endsWith(".jpg")) .filter((f) => f.endsWith(".jpg"))
.sort(); .sort();
// ±3 tolerance: same FFmpeg VFR→CFR rounding variance as the mid-segment test. // ±3 tolerance: same FFmpeg one-pass VFR→CFR rounding variance as the
// mid-segment test.
expect(frames.length).toBeGreaterThanOrEqual(297); expect(frames.length).toBeGreaterThanOrEqual(297);
expect(frames.length).toBeLessThanOrEqual(303); expect(frames.length).toBeLessThanOrEqual(303);
let prevHash: string | null = null;
let duplicates = 0;
for (const f of frames) {
const hash = createHash("sha256")
.update(readFileSync(join(frameDir, f)))
.digest("hex");
if (hash === prevHash) duplicates += 1;
prevHash = hash;
}
const duplicateRate = duplicates / frames.length;
expect(duplicateRate).toBeLessThan(0.1);
}, 60_000); }, 60_000);
}); });
@@ -87,16 +87,22 @@ export interface ExtractionOptions {
* *
* Used by the producer to surface `perfSummary.videoExtractBreakdown` — without * Used by the producer to surface `perfSummary.videoExtractBreakdown` — without
* this breakdown, a single `videoExtractMs` stage timing hides where cost lives * this breakdown, a single `videoExtractMs` stage timing hides where cost lives
* (HDR preflight, VFR preflight, per-video ffmpeg extract) when tuning renders. * (HDR preflight, VFR classification, per-video ffmpeg extract) when tuning renders.
* *
* Field semantics: * Field semantics:
* - *Ms fields are wall-clock durations inside each phase. * - *Ms fields are wall-clock durations inside each phase.
* - *Count fields report how many sources triggered that phase. * - *Count fields report how many sources triggered that phase.
* - extractMs wraps the parallel `extractVideoFramesRange` calls; it * - extractMs wraps the parallel `extractVideoFramesRange` calls; it
* reflects max-across-parallel-workers, not sum. * reflects max-across-parallel-workers, not sum.
* - hdrPreflightMs / vfrPreflightMs both include their probe-time sibling * - hdrPreflightMs includes its probe-time sibling (hdrProbeMs); the
* (hdrProbeMs / vfrProbeMs) for symmetric semantics. The probe-only fields * probe-only field is a finer decomposition, not a separate carve-out.
* are a finer decomposition, not a separate carve-out. * - vfrPreflightCount reports sources classified as VFR and routed through
* the one-pass `-fps_mode cfr -r` extraction path. DEFINITION CHANGE:
* before the one-pass refactor, vfrPreflightMs timed a per-source
* VFR-to-CFR re-encode and could reach seconds; it now times only the
* (promise-cached) classification probe and is expected to be ~0.
* Dashboards alerting on vfrPreflightMs thresholds should key on
* vfrPreflightCount or extractMs instead.
*/ */
export interface ExtractionPhaseBreakdown { export interface ExtractionPhaseBreakdown {
resolveMs: number; resolveMs: number;
@@ -267,8 +273,11 @@ export async function extractVideoFramesRange(
// VideoToolbox tone-maps during decode; force output to bt709 SDR format // VideoToolbox tone-maps during decode; force output to bt709 SDR format
vfFilters.push("format=nv12"); vfFilters.push("format=nv12");
} }
vfFilters.push(`fps=${fps}`); if (!metadata.isVFR) {
args.push("-vf", vfFilters.join(",")); vfFilters.push(`fps=${fps}`);
}
if (vfFilters.length > 0) args.push("-vf", vfFilters.join(","));
if (metadata.isVFR) args.push("-fps_mode", "cfr", "-r", String(fps));
args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"); args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0");
// Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files. // Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
@@ -356,7 +365,6 @@ export async function extractVideoFramesRange(
* `startTime` and `duration` bound the re-encode to the segment the composition * `startTime` and `duration` bound the re-encode to the segment the composition
* actually uses. Without them a 30-minute screen recording that contributes a * actually uses. Without them a 30-minute screen recording that contributes a
* 2-second clip was transcoded in full — a >100× waste for long sources. * 2-second clip was transcoded in full — a >100× waste for long sources.
* Mirrors the segment-scope fix already applied to the VFR→CFR preflight.
*/ */
async function convertSdrToHdr( async function convertSdrToHdr(
inputPath: string, inputPath: string,
@@ -457,62 +465,6 @@ export function resolveFrameFormat(
return "jpg"; return "jpg";
} }
/**
* 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
* some webcams emit irregular timestamps that cause two failure modes:
* 1. Output has fewer frames than expected (e.g. -ss 3 -t 4 produces 90
* frames instead of 120 @ 30fps). FrameLookupTable.getFrameAtTime then
* returns null for late timestamps and the caller freezes on the last
* valid frame.
* 2. Large duplicate-frame runs where source PTS don't land on target
* timestamps.
*
* Only the [startTime, startTime+duration] window is re-encoded, so long
* recordings aren't fully transcoded when only a short clip is used.
*/
async function convertVfrToCfr(
inputPath: string,
outputPath: string,
targetFps: number,
startTime: number,
duration: number,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
): Promise<void> {
const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
const args = [
"-ss",
String(startTime),
"-i",
inputPath,
"-t",
String(duration),
"-fps_mode",
"cfr",
"-r",
String(targetFps),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-c:a",
"copy",
"-y",
outputPath,
];
const result = await runFfmpeg(args, { signal, timeout });
if (!result.success) {
throw new Error(
`VFR→CFR conversion failed (exit ${result.exitCode}): ${result.stderr.slice(-300)}`,
);
}
}
/** /**
* Resolve a relative `<video src>` to a filesystem path the way the browser * Resolve a relative `<video src>` to a filesystem path the way the browser
* resolves it as a URL. Browsers clamp `..` segments at the served origin's * resolves it as a URL. Browsers clamp `..` segments at the served origin's
@@ -648,8 +600,8 @@ export async function extractAllVideoFrames(
// Snapshot the pre-preflight key inputs so the extraction cache keys on the // Snapshot the pre-preflight key inputs so the extraction cache keys on the
// user-visible source (original path, original mediaStart, original segment // user-visible source (original path, original mediaStart, original segment
// bounds) rather than the workDir-local normalized file produced by // bounds) rather than the workDir-local normalized file produced by the
// Phase 2a/2b preflight. Without this, every render would write a new // HDR preflight. Without this, every render would write a new
// normalized file with a fresh mtime → fresh cache key → perpetual misses. // normalized file with a fresh mtime → fresh cache key → perpetual misses.
const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => { const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => {
const stat = readKeyStat(videoPath); const stat = readKeyStat(videoPath);
@@ -741,7 +693,7 @@ export async function extractAllVideoFrames(
entry.videoPath = convertedPath; entry.videoPath = convertedPath;
// Segment-scoped re-encode starts the new file at t=0, so downstream // Segment-scoped re-encode starts the new file at t=0, so downstream
// extraction must seek from 0, not the original mediaStart. Shallow-copy // extraction must seek from 0, not the original mediaStart. Shallow-copy
// to avoid mutating the caller's VideoElement (mirrors the VFR fix). // to avoid mutating the caller's VideoElement.
entry.video = { ...entry.video, mediaStart: 0 }; entry.video = { ...entry.video, mediaStart: 0 };
breakdown.hdrPreflightCount += 1; breakdown.hdrPreflightCount += 1;
} catch (err) { } catch (err) {
@@ -756,8 +708,8 @@ export async function extractAllVideoFrames(
breakdown.hdrPreflightMs = Date.now() - hdrPreflightStart; breakdown.hdrPreflightMs = Date.now() - hdrPreflightStart;
// Remove HDR-preflight-skipped entries from every parallel array so Phase 2b // Remove HDR-preflight-skipped entries from every parallel array so Phase 2b
// (VFR) and Phase 3 (extract) don't re-process them. Iterate backwards to // (VFR classification) and Phase 3 (extract) don't re-process them. Iterate
// keep indices stable while splicing. // backwards to keep indices stable while splicing.
if (hdrSkippedIndices.size > 0) { if (hdrSkippedIndices.size > 0) {
for (let i = resolvedVideos.length - 1; i >= 0; i--) { for (let i = resolvedVideos.length - 1; i >= 0; i--) {
if (hdrSkippedIndices.has(i)) { if (hdrSkippedIndices.has(i)) {
@@ -772,10 +724,9 @@ export async function extractAllVideoFrames(
} }
} }
// Phase 2b: Re-encode VFR inputs to CFR so the fps filter in Phase 3 produces // Phase 2b: Keep VFR observability while routing VFR inputs through the
// the expected frame count. Only the used segment is transcoded. // one-pass CFR extraction path in Phase 3.
const vfrPreflightStart = Date.now(); const vfrPreflightStart = Date.now();
const vfrNormDir = join(options.outputDir, "_vfr_normalized");
for (let i = 0; i < resolvedVideos.length; i++) { for (let i = 0; i < resolvedVideos.length; i++) {
if (signal?.aborted) break; if (signal?.aborted) break;
const entry = resolvedVideos[i]; const entry = resolvedVideos[i];
@@ -783,38 +734,7 @@ export async function extractAllVideoFrames(
const vfrProbeStart = Date.now(); const vfrProbeStart = Date.now();
const metadata = await extractMediaMetadata(entry.videoPath); const metadata = await extractMediaMetadata(entry.videoPath);
breakdown.vfrProbeMs += Date.now() - vfrProbeStart; breakdown.vfrProbeMs += Date.now() - vfrProbeStart;
if (!metadata.isVFR) continue; if (metadata.isVFR) breakdown.vfrPreflightCount += 1;
let segDuration = entry.video.end - entry.video.start;
if (!Number.isFinite(segDuration) || segDuration <= 0) {
const sourceRemaining = metadata.durationSeconds - entry.video.mediaStart;
segDuration = sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
}
mkdirSync(vfrNormDir, { recursive: true });
const normalizedPath = join(vfrNormDir, `${entry.video.id}_cfr.mp4`);
try {
await convertVfrToCfr(
entry.videoPath,
normalizedPath,
options.fps,
entry.video.mediaStart,
segDuration,
signal,
config,
);
entry.videoPath = normalizedPath;
// Segment-scoped re-encode starts the new file at t=0, so downstream
// extraction must seek from 0, not the original mediaStart. Shallow-copy
// to avoid mutating the caller's VideoElement.
entry.video = { ...entry.video, mediaStart: 0 };
breakdown.vfrPreflightCount += 1;
} catch (err) {
errors.push({
videoId: entry.video.id,
error: err instanceof Error ? err.message : String(err),
});
}
} }
breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart; breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart;