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", () => {
|
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, 18–44% 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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user