mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -33,7 +33,7 @@ const keyFor = (videoPath: string, overrides: Partial<CacheKeyInput> = {}): Cach
|
||||
|
||||
describe("extractionCache constants", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -39,8 +39,14 @@ export const FRAME_FILENAME_PREFIX = "frame_";
|
||||
/** Sentinel filename written after a cache entry is fully populated. */
|
||||
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. */
|
||||
const KEY_HEX_CHARS = 16;
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
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.
|
||||
// FrameLookupTable.getFrameAtTime then returns null for out-of-range indices
|
||||
// and the compositor holds the last valid frame, which the user perceives as
|
||||
// the video freezing. extractAllVideoFrames normalizes VFR sources to CFR
|
||||
// before extraction to fix this.
|
||||
// the video freezing. extractAllVideoFrames now routes VFR sources through
|
||||
// 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", () => {
|
||||
const FIXTURE_DIR = mkdtempSync(join(tmpdir(), "hf-vfr-test-"));
|
||||
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);
|
||||
const frames = readdirSync(join(outputDir, "v1")).filter((f) => f.endsWith(".jpg"));
|
||||
// Pre-fix behavior produced ~90 frames (a 25% shortfall).
|
||||
// ±3 tolerance: FFmpeg's VFR→CFR normalization yields slightly different
|
||||
// frame counts across versions (timestamp rounding in the fps filter).
|
||||
// ±3 tolerance: FFmpeg's one-pass VFR→CFR extraction yields slightly
|
||||
// different frame counts across versions (timestamp rounding).
|
||||
expect(frames.length).toBeGreaterThanOrEqual(117);
|
||||
expect(frames.length).toBeLessThanOrEqual(123);
|
||||
|
||||
expect(result.phaseBreakdown).toBeDefined();
|
||||
expect(result.phaseBreakdown.extractMs).toBeGreaterThan(0);
|
||||
expect(result.phaseBreakdown.vfrPreflightCount).toBe(1);
|
||||
expect(result.phaseBreakdown.vfrPreflightMs).toBeGreaterThan(0);
|
||||
expect(result.phaseBreakdown.vfrPreflightMs).toBeGreaterThanOrEqual(0);
|
||||
}, 60_000);
|
||||
|
||||
it("reuses extracted frames on a warm cache hit", async () => {
|
||||
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
||||
const SRC = join(FIXTURE_DIR, "cache-src.mp4");
|
||||
|
||||
// Synthesize a clean CFR SDR clip — bypasses VFR preflight so the cache
|
||||
// key is stable across the two runs.
|
||||
// Synthesize a clean CFR SDR clip — keeps VFR preflight count at zero so
|
||||
// the cache key is stable across the two runs.
|
||||
const synth = await runFfmpeg([
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
@@ -1012,13 +1004,10 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
expect(convertedMeta.durationSeconds).toBeLessThan(2.5);
|
||||
}, 60_000);
|
||||
|
||||
// Asserts both frame-count correctness and that we don't emit long runs of
|
||||
// byte-identical "duplicate" frames — the user-visible "frozen screen
|
||||
// recording" symptom. Pre-fix duplicate rate on this fixture is ~38%
|
||||
// (116/300); on the actual reporter's ScreenCaptureKit clip, 18–44% across
|
||||
// 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 () => {
|
||||
// Asserts frame-count correctness for a full VFR file. One-pass CFR image
|
||||
// extraction may repeat held source frames across timestamp gaps; the freeze
|
||||
// regression is missing frames, which leaves late timeline lookups null.
|
||||
it("produces the full frame count on the full VFR file", async () => {
|
||||
const outputDir = join(FIXTURE_DIR, "out-full");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
@@ -1042,21 +1031,10 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
const frames = readdirSync(frameDir)
|
||||
.filter((f) => f.endsWith(".jpg"))
|
||||
.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).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);
|
||||
});
|
||||
|
||||
|
||||
@@ -87,16 +87,22 @@ export interface ExtractionOptions {
|
||||
*
|
||||
* Used by the producer to surface `perfSummary.videoExtractBreakdown` — without
|
||||
* 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:
|
||||
* - *Ms fields are wall-clock durations inside each phase.
|
||||
* - *Count fields report how many sources triggered that phase.
|
||||
* - extractMs wraps the parallel `extractVideoFramesRange` calls; it
|
||||
* reflects max-across-parallel-workers, not sum.
|
||||
* - hdrPreflightMs / vfrPreflightMs both include their probe-time sibling
|
||||
* (hdrProbeMs / vfrProbeMs) for symmetric semantics. The probe-only fields
|
||||
* are a finer decomposition, not a separate carve-out.
|
||||
* - hdrPreflightMs includes its probe-time sibling (hdrProbeMs); the
|
||||
* probe-only field is 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 {
|
||||
resolveMs: number;
|
||||
@@ -267,8 +273,11 @@ export async function extractVideoFramesRange(
|
||||
// VideoToolbox tone-maps during decode; force output to bt709 SDR format
|
||||
vfFilters.push("format=nv12");
|
||||
}
|
||||
vfFilters.push(`fps=${fps}`);
|
||||
args.push("-vf", vfFilters.join(","));
|
||||
if (!metadata.isVFR) {
|
||||
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");
|
||||
// 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
|
||||
* 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.
|
||||
* Mirrors the segment-scope fix already applied to the VFR→CFR preflight.
|
||||
*/
|
||||
async function convertSdrToHdr(
|
||||
inputPath: string,
|
||||
@@ -457,62 +465,6 @@ export function resolveFrameFormat(
|
||||
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
|
||||
* 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
|
||||
// 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
|
||||
// bounds) rather than the workDir-local normalized file produced by the
|
||||
// HDR 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);
|
||||
@@ -741,7 +693,7 @@ export async function extractAllVideoFrames(
|
||||
entry.videoPath = convertedPath;
|
||||
// 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 (mirrors the VFR fix).
|
||||
// to avoid mutating the caller's VideoElement.
|
||||
entry.video = { ...entry.video, mediaStart: 0 };
|
||||
breakdown.hdrPreflightCount += 1;
|
||||
} catch (err) {
|
||||
@@ -756,8 +708,8 @@ export async function extractAllVideoFrames(
|
||||
breakdown.hdrPreflightMs = Date.now() - hdrPreflightStart;
|
||||
|
||||
// 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
|
||||
// keep indices stable while splicing.
|
||||
// (VFR classification) and Phase 3 (extract) don't re-process them. Iterate
|
||||
// backwards to keep indices stable while splicing.
|
||||
if (hdrSkippedIndices.size > 0) {
|
||||
for (let i = resolvedVideos.length - 1; i >= 0; 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
|
||||
// the expected frame count. Only the used segment is transcoded.
|
||||
// Phase 2b: Keep VFR observability while routing VFR inputs through the
|
||||
// one-pass CFR extraction path in Phase 3.
|
||||
const vfrPreflightStart = Date.now();
|
||||
const vfrNormDir = join(options.outputDir, "_vfr_normalized");
|
||||
for (let i = 0; i < resolvedVideos.length; i++) {
|
||||
if (signal?.aborted) break;
|
||||
const entry = resolvedVideos[i];
|
||||
@@ -783,38 +734,7 @@ export async function extractAllVideoFrames(
|
||||
const vfrProbeStart = Date.now();
|
||||
const metadata = await extractMediaMetadata(entry.videoPath);
|
||||
breakdown.vfrProbeMs += Date.now() - vfrProbeStart;
|
||||
if (!metadata.isVFR) continue;
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
if (metadata.isVFR) breakdown.vfrPreflightCount += 1;
|
||||
}
|
||||
breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user