mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
perf(engine): superset extraction for overlapping trims of one source (#1885)
* perf(engine): superset extraction for overlapping trims of one source Cache-missing trims of the same source that are frame-aligned and overlapping decode their union window in ONE ffmpeg pass; each trim's frames are materialized by hardlinking the superset frames with renumbered names (copy fallback on EXDEV). Byte-identical to per-trim extraction on CFR sources (verified by content hash in the A/B run), ~2x less decode+encode work for typical overlapping trims, and sparse-keyframe sources pay the keyframe seek once instead of once per trim. Disjoint or misaligned trims keep the direct path; any union failure falls back to per-trim extraction. Also: warm renders (zero cache misses) skip the extraction-cache GC sweep instead of paying a full cache size scan. * fix(engine): superset review hardening - clustering, abort, cache-fs temp, gc staleness - Partition each source's trims into overlap-connected components before the union check, so one disjoint outlier no longer collapses the whole bucket to direct extraction (pinned by a 3-of-4-overlap test). - On abort, the superset fallback no longer re-runs every member through direct extraction (N doomed ffmpeg spawns); the cancellation surfaces per member instead. - The superset temp dir moves onto the cache filesystem when the cache is active so member hardlinks into partial dirs cannot EXDEV-copy and silently multiply disk usage; its .partial- name puts crashed leftovers under the GC's aged-partial sweep. - GC staleness fallback: a .hf-last-gc marker is stamped per sweep and all-hit renders sweep anyway once it is older than 24h, so 100%-warm workloads still reclaim space (pinned by a stale-marker test).
This commit is contained in:
@@ -53,6 +53,9 @@ export const FRAME_FILENAME_PREFIX = "frame_";
|
||||
/** Sentinel filename written after a cache entry is fully populated. */
|
||||
export const COMPLETE_SENTINEL = ".hf-complete";
|
||||
|
||||
/** Marker file stamped after each GC sweep; drives the staleness fallback. */
|
||||
export const GC_MARKER = ".hf-last-gc";
|
||||
|
||||
/**
|
||||
* Current schema version. Bump when the cache-contents invariant changes.
|
||||
* v2 -> v3: one-pass VFR extraction (-fps_mode cfr) replaces the two-pass
|
||||
@@ -390,11 +393,29 @@ export interface GcStats {
|
||||
* a liveness heuristic, not a lock. Returns counts so the caller can surface
|
||||
* eviction pressure in render observability.
|
||||
*/
|
||||
/**
|
||||
* Whether the staleness fallback should force a sweep: true when no sweep
|
||||
* marker exists or the last sweep is older than `maxAgeMs`. Lets 100%-warm
|
||||
* workloads (which skip the per-miss sweep) still reclaim space eventually.
|
||||
*/
|
||||
export function gcSweepDue(rootDir: string, maxAgeMs: number): boolean {
|
||||
try {
|
||||
return Date.now() - statSync(join(rootDir, GC_MARKER)).mtimeMs > maxAgeMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function gcExtractionCache(
|
||||
rootDir: string,
|
||||
opts: { maxBytes: number; minAgeMs: number },
|
||||
): GcStats {
|
||||
const stats: GcStats = { evictedEntries: 0, evictedBytes: 0, agedPartialsRemoved: 0 };
|
||||
try {
|
||||
writeFileSync(join(rootDir, GC_MARKER), "", "utf-8");
|
||||
} catch {
|
||||
// Unwritable root: the sweep below will no-op on the same root anyway.
|
||||
}
|
||||
try {
|
||||
const now = Date.now();
|
||||
const entries: GcEntry[] = [];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./videoFrameExtractor.js";
|
||||
import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { COMPLETE_SENTINEL, SCHEMA_PREFIX } from "./extractionCache.js";
|
||||
import { COMPLETE_SENTINEL, GC_MARKER, SCHEMA_PREFIX } from "./extractionCache.js";
|
||||
|
||||
// ffmpeg is not preinstalled on GitHub's ubuntu-24.04 runners. The producer
|
||||
// regression test at packages/producer/tests/vfr-screen-recording/ runs inside
|
||||
@@ -853,23 +853,6 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
return src;
|
||||
}
|
||||
|
||||
function cfrClipElement(id: string, src: string, endSeconds: number): VideoElement {
|
||||
return { id, src, start: 0, end: endSeconds, mediaStart: 0, loop: false, hasAudio: false };
|
||||
}
|
||||
|
||||
async function extractWithCache(
|
||||
video: VideoElement,
|
||||
outName: string,
|
||||
cacheDir: string,
|
||||
fps = 30,
|
||||
): Promise<ExtractionResult> {
|
||||
const outputDir = join(FIXTURE_DIR, outName);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
return extractAllVideoFrames([video], FIXTURE_DIR, { fps, outputDir }, undefined, {
|
||||
extractCacheDir: cacheDir,
|
||||
});
|
||||
}
|
||||
|
||||
async function synthHdrTaggedClip(name: string, durationSeconds: number): Promise<string> {
|
||||
const src = join(FIXTURE_DIR, name);
|
||||
const synth = await runFfmpeg([
|
||||
@@ -901,10 +884,45 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
return src;
|
||||
}
|
||||
|
||||
function cfrClipElement(
|
||||
id: string,
|
||||
src: string,
|
||||
endSeconds: number,
|
||||
mediaStart = 0,
|
||||
): VideoElement {
|
||||
return {
|
||||
id,
|
||||
src,
|
||||
start: 0,
|
||||
end: endSeconds,
|
||||
mediaStart,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function extractWithCache(
|
||||
video: VideoElement,
|
||||
outName: string,
|
||||
cacheDir: string,
|
||||
fps = 30,
|
||||
): Promise<ExtractionResult> {
|
||||
const outputDir = join(FIXTURE_DIR, outName);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
return extractAllVideoFrames([video], FIXTURE_DIR, { fps, outputDir }, undefined, {
|
||||
extractCacheDir: cacheDir,
|
||||
});
|
||||
}
|
||||
|
||||
function cacheEntryNames(cacheDir: string): string[] {
|
||||
return readdirSync(cacheDir).filter((name) => name.startsWith(SCHEMA_PREFIX));
|
||||
}
|
||||
|
||||
function supersetDirNames(outputDir: string): string[] {
|
||||
if (!existsSync(outputDir)) return [];
|
||||
return readdirSync(outputDir).filter((name) => name.startsWith("__superset-"));
|
||||
}
|
||||
|
||||
function extractedFor(result: ExtractionResult, videoId: string): ExtractedFrames {
|
||||
const extracted = result.extracted.find((item) => item.videoId === videoId);
|
||||
if (!extracted) throw new Error(`missing extraction result for ${videoId}`);
|
||||
@@ -967,6 +985,30 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
rmSync(CACHE_DIR, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("skips cache GC on all-hit renders", async () => {
|
||||
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-gc-skip-test-"));
|
||||
const SRC = await synthCfrClip("cache-gc-skip-src.mp4", 1);
|
||||
const video = cfrClipElement("gc-skip", SRC, 1);
|
||||
|
||||
const miss = await extractWithCache(video, "out-cache-gc-skip-miss", CACHE_DIR);
|
||||
expect(miss.errors).toEqual([]);
|
||||
expect(miss.phaseBreakdown.cacheMisses).toBe(1);
|
||||
|
||||
const agedPartial = join(CACHE_DIR, `${SCHEMA_PREFIX}aged.partial-1234-deadbeef`);
|
||||
mkdirSync(agedPartial, { recursive: true });
|
||||
writeFileSync(join(agedPartial, "frame_00001.jpg"), "stale", "utf-8");
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
utimesSync(agedPartial, old, old);
|
||||
|
||||
const hit = await extractWithCache(video, "out-cache-gc-skip-hit", CACHE_DIR);
|
||||
expect(hit.errors).toEqual([]);
|
||||
expect(hit.phaseBreakdown.cacheHits).toBe(1);
|
||||
expect(hit.phaseBreakdown.cacheMisses).toBe(0);
|
||||
expect(existsSync(agedPartial)).toBe(true);
|
||||
|
||||
rmSync(CACHE_DIR, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("disables caching for this render when the cache dir is not writable", async () => {
|
||||
const CACHE_FILE = join(FIXTURE_DIR, "cache-dir-is-a-file");
|
||||
writeFileSync(CACHE_FILE, "not a directory", "utf-8");
|
||||
@@ -1110,6 +1152,197 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("clusters overlap components so a disjoint outlier does not break the group", async () => {
|
||||
const SRC = await synthCfrClip("superset-cluster-src.mp4", 12);
|
||||
const outputDir = join(FIXTURE_DIR, "out-superset-cluster");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Three overlapping trims [0..4], [2..6], [4..8] plus one disjoint trim
|
||||
// [10..12] of the same source. Pre-clustering, the outlier failed the
|
||||
// union<=sum check for the whole bucket and ALL FOUR fell back to direct
|
||||
// extraction; the overlapping three must still share one superset.
|
||||
const result = await extractAllVideoFrames(
|
||||
[
|
||||
cfrClipElement("cl-a", SRC, 4, 0),
|
||||
cfrClipElement("cl-b", SRC, 4, 2),
|
||||
cfrClipElement("cl-c", SRC, 4, 4),
|
||||
cfrClipElement("cl-out", SRC, 2, 10),
|
||||
],
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
// Overlap region t=2..4 of the source: cl-a frame 60 and cl-b frame 0
|
||||
// must be the SAME inode (shared superset extraction).
|
||||
expect(statSync(framePath(result, "cl-a", 60)).ino).toBe(
|
||||
statSync(framePath(result, "cl-b", 0)).ino,
|
||||
);
|
||||
expect(statSync(framePath(result, "cl-b", 60)).ino).toBe(
|
||||
statSync(framePath(result, "cl-c", 0)).ino,
|
||||
);
|
||||
// The outlier extracted directly: its frames share no inode with the
|
||||
// cluster (frame at source t=10 exists only in its own extraction).
|
||||
expect(extractedFor(result, "cl-out").totalFrames).toBe(60);
|
||||
expect(supersetDirNames(outputDir)).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("runs the GC staleness fallback sweep on all-hit renders with a stale marker", async () => {
|
||||
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-gc-stale-test-"));
|
||||
const SRC = await synthCfrClip("cache-gc-stale-src.mp4", 1);
|
||||
const video = cfrClipElement("gc-stale", SRC, 1);
|
||||
|
||||
const miss = await extractWithCache(video, "out-cache-gc-stale-miss", CACHE_DIR);
|
||||
expect(miss.phaseBreakdown.cacheMisses).toBe(1);
|
||||
|
||||
const agedPartial = join(CACHE_DIR, `${SCHEMA_PREFIX}aged.partial-1234-cafef00d`);
|
||||
mkdirSync(agedPartial, { recursive: true });
|
||||
writeFileSync(join(agedPartial, "frame_00001.jpg"), "stale", "utf-8");
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
utimesSync(agedPartial, twoHoursAgo, twoHoursAgo);
|
||||
|
||||
// Age the sweep marker past the 24h staleness window: the next all-hit
|
||||
// render must sweep anyway and clear the aged partial.
|
||||
const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
utimesSync(join(CACHE_DIR, GC_MARKER), twoDaysAgo, twoDaysAgo);
|
||||
|
||||
const hit = await extractWithCache(video, "out-cache-gc-stale-hit", CACHE_DIR);
|
||||
expect(hit.phaseBreakdown.cacheHits).toBe(1);
|
||||
expect(hit.phaseBreakdown.cacheMisses).toBe(0);
|
||||
expect(existsSync(agedPartial)).toBe(false);
|
||||
expect(hit.phaseBreakdown.cacheAgedPartialsCleared).toBe(1);
|
||||
|
||||
rmSync(CACHE_DIR, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
it("hardlinks overlapping aligned trims from one superset extraction", async () => {
|
||||
const SRC = await synthCfrClip("superset-overlap-src.mp4", 10);
|
||||
const outputDir = join(FIXTURE_DIR, "out-superset-overlap");
|
||||
const directOutputDir = join(FIXTURE_DIR, "out-superset-direct");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
mkdirSync(directOutputDir, { recursive: true });
|
||||
|
||||
const result = await extractAllVideoFrames(
|
||||
[cfrClipElement("trim-a", SRC, 4, 0), cfrClipElement("trim-b", SRC, 4, 2)],
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(extractedFor(result, "trim-a").totalFrames).toBe(120);
|
||||
expect(extractedFor(result, "trim-b").totalFrames).toBe(120);
|
||||
expect(statSync(framePath(result, "trim-a", 60)).ino).toBe(
|
||||
statSync(framePath(result, "trim-b", 0)).ino,
|
||||
);
|
||||
|
||||
const direct = await extractVideoFramesRange(SRC, "direct-trim-b", 2, 4, {
|
||||
fps: 30,
|
||||
outputDir: directOutputDir,
|
||||
format: "jpg",
|
||||
});
|
||||
expect(
|
||||
readFileSync(framePath(result, "trim-b", 0)).equals(readFileSync(direct.framePaths.get(0)!)),
|
||||
).toBe(true);
|
||||
expect(supersetDirNames(outputDir)).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("does not superset disjoint trims", async () => {
|
||||
const SRC = await synthCfrClip("superset-disjoint-src.mp4", 10);
|
||||
const outputDir = join(FIXTURE_DIR, "out-superset-disjoint");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const result = await extractAllVideoFrames(
|
||||
[cfrClipElement("trim-a", SRC, 2, 0), cfrClipElement("trim-b", SRC, 2, 8)],
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(statSync(framePath(result, "trim-a", 0)).ino).not.toBe(
|
||||
statSync(framePath(result, "trim-b", 0)).ino,
|
||||
);
|
||||
expect(supersetDirNames(outputDir)).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("does not superset trims whose offsets are not frame-aligned", async () => {
|
||||
const SRC = await synthCfrClip("superset-misaligned-src.mp4", 2);
|
||||
const outputDir = join(FIXTURE_DIR, "out-superset-misaligned");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const result = await extractAllVideoFrames(
|
||||
[cfrClipElement("trim-a", SRC, 1, 0), cfrClipElement("trim-b", SRC, 1, 0.017)],
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(statSync(framePath(result, "trim-a", 0)).ino).not.toBe(
|
||||
statSync(framePath(result, "trim-b", 0)).ino,
|
||||
);
|
||||
expect(supersetDirNames(outputDir)).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("publishes overlapping superset slices to cache entries and hits them on the next render", async () => {
|
||||
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-superset-cache-test-"));
|
||||
const SRC = await synthCfrClip("superset-cache-src.mp4", 10);
|
||||
try {
|
||||
const firstOutputDir = join(FIXTURE_DIR, "out-superset-cache-first");
|
||||
const secondOutputDir = join(FIXTURE_DIR, "out-superset-cache-second");
|
||||
mkdirSync(firstOutputDir, { recursive: true });
|
||||
mkdirSync(secondOutputDir, { recursive: true });
|
||||
const videos = [cfrClipElement("trim-a", SRC, 4, 0), cfrClipElement("trim-b", SRC, 4, 2)];
|
||||
|
||||
const first = await extractAllVideoFrames(
|
||||
videos,
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir: firstOutputDir },
|
||||
undefined,
|
||||
{ extractCacheDir: CACHE_DIR },
|
||||
);
|
||||
expect(first.errors).toEqual([]);
|
||||
expect(first.phaseBreakdown.cacheHits).toBe(0);
|
||||
expect(first.phaseBreakdown.cacheMisses).toBe(2);
|
||||
expect(cacheEntryNames(CACHE_DIR)).toHaveLength(2);
|
||||
expect(statSync(framePath(first, "trim-a", 60)).ino).toBe(
|
||||
statSync(framePath(first, "trim-b", 0)).ino,
|
||||
);
|
||||
|
||||
const second = await extractAllVideoFrames(
|
||||
videos,
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir: secondOutputDir },
|
||||
undefined,
|
||||
{ extractCacheDir: CACHE_DIR },
|
||||
);
|
||||
expect(second.errors).toEqual([]);
|
||||
expect(second.phaseBreakdown.cacheHits).toBe(2);
|
||||
expect(second.phaseBreakdown.cacheMisses).toBe(0);
|
||||
expect(supersetDirNames(secondOutputDir)).toEqual([]);
|
||||
} finally {
|
||||
rmSync(CACHE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("clamps loop-past-EOF superset slices to available source frames", async () => {
|
||||
const SRC = await synthCfrClip("superset-eof-src.mp4", 10);
|
||||
const outputDir = join(FIXTURE_DIR, "out-superset-eof");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const result = await extractAllVideoFrames(
|
||||
[cfrClipElement("covered", SRC, 4, 6), cfrClipElement("past-eof", SRC, 6, 8)],
|
||||
FIXTURE_DIR,
|
||||
{ fps: 30, outputDir },
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(extractedFor(result, "covered").totalFrames).toBe(120);
|
||||
expect(extractedFor(result, "past-eof").totalFrames).toBe(60);
|
||||
expect(statSync(framePath(result, "covered", 60)).ino).toBe(
|
||||
statSync(framePath(result, "past-eof", 0)).ino,
|
||||
);
|
||||
expect(supersetDirNames(outputDir)).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { isAbsolute, join, posix, resolve, sep } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { decodeUrlPathVariants, MEDIA_DURATION_CLAMP_EPSILON_SECONDS } from "@hyperframes/core";
|
||||
@@ -25,12 +25,14 @@ import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
import {
|
||||
FRAME_FILENAME_PREFIX,
|
||||
gcExtractionCache,
|
||||
gcSweepDue,
|
||||
lookupCacheEntry,
|
||||
partialCacheEntryDir,
|
||||
publishCacheEntry,
|
||||
readKeyStat,
|
||||
rehydrateCacheEntry,
|
||||
touchCacheEntry,
|
||||
type CacheEntry,
|
||||
type CacheFrameFormat,
|
||||
} from "./extractionCache.js";
|
||||
|
||||
@@ -85,6 +87,7 @@ export interface ExtractionOptions {
|
||||
}
|
||||
|
||||
const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
|
||||
const GC_STALENESS_MS = 24 * 60 * 60 * 1000;
|
||||
const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv";
|
||||
|
||||
function sdrToHdrTransformKey(transfer: HdrTransfer): string {
|
||||
@@ -432,6 +435,205 @@ export function resolveFrameFormat(
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
type PreparedExtraction = {
|
||||
video: VideoElement;
|
||||
videoPath: string;
|
||||
index: number;
|
||||
metadata: VideoMetadata;
|
||||
videoDuration: number;
|
||||
format: CacheFrameFormat;
|
||||
sdrToHdrTransfer?: HdrTransfer;
|
||||
dedupeKey: string;
|
||||
};
|
||||
|
||||
type CacheMissTarget = {
|
||||
entry: CacheEntry;
|
||||
srcPath: string;
|
||||
};
|
||||
|
||||
type UniqueExtractionMiss = {
|
||||
work: PreparedExtraction;
|
||||
cacheTarget?: CacheMissTarget;
|
||||
};
|
||||
|
||||
type SupersetMemberPlan = {
|
||||
miss: UniqueExtractionMiss;
|
||||
offsetFrames: number;
|
||||
};
|
||||
|
||||
type SupersetGroupPlan = {
|
||||
groupId: string;
|
||||
baseStart: number;
|
||||
unionDuration: number;
|
||||
members: SupersetMemberPlan[];
|
||||
};
|
||||
|
||||
function extractedFrameFileNames(outputDir: string, format: CacheFrameFormat): string[] {
|
||||
const suffix = `.${format}`;
|
||||
return readdirSync(outputDir)
|
||||
.filter((file) => file.startsWith(FRAME_FILENAME_PREFIX) && file.endsWith(suffix))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function extractedFramesFromDirectory(
|
||||
work: PreparedExtraction,
|
||||
outputDir: string,
|
||||
srcPath: string,
|
||||
fps: number,
|
||||
): ExtractedFrames {
|
||||
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${work.format}`;
|
||||
const framePaths = new Map<number, string>();
|
||||
extractedFrameFileNames(outputDir, work.format).forEach((file, index) => {
|
||||
framePaths.set(index, join(outputDir, file));
|
||||
});
|
||||
return {
|
||||
videoId: work.video.id,
|
||||
srcPath,
|
||||
outputDir,
|
||||
framePattern,
|
||||
fps,
|
||||
totalFrames: framePaths.size,
|
||||
metadata: work.metadata,
|
||||
framePaths,
|
||||
};
|
||||
}
|
||||
|
||||
function frameFileName(frameNumber: number, format: CacheFrameFormat): string {
|
||||
return `${FRAME_FILENAME_PREFIX}${String(frameNumber).padStart(5, "0")}.${format}`;
|
||||
}
|
||||
|
||||
function linkOrCopyFrame(src: string, dest: string): void {
|
||||
try {
|
||||
linkSync(src, dest);
|
||||
} catch {
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
function supersetGroupingKey(work: PreparedExtraction, fps: number): string {
|
||||
return [work.videoPath, String(fps), work.format, work.sdrToHdrTransfer ?? ""].join("\0");
|
||||
}
|
||||
|
||||
function isIntegralFrameOffset(offsetSeconds: number, fps: number): boolean {
|
||||
const frames = offsetSeconds * fps;
|
||||
return Math.abs(frames - Math.round(frames)) <= 1e-4;
|
||||
}
|
||||
|
||||
function windowsOverlapOrTouch(misses: UniqueExtractionMiss[], baseStart: number): boolean {
|
||||
const unionEnd = Math.max(
|
||||
...misses.map(({ work }) => work.video.mediaStart + work.videoDuration),
|
||||
);
|
||||
const unionDuration = unionEnd - baseStart;
|
||||
const summedDuration = misses.reduce((sum, { work }) => sum + work.videoDuration, 0);
|
||||
return unionDuration > 0 && unionDuration <= summedDuration + 1e-9;
|
||||
}
|
||||
|
||||
function buildSupersetGroup(
|
||||
groupId: string,
|
||||
misses: UniqueExtractionMiss[],
|
||||
fps: number,
|
||||
): SupersetGroupPlan | null {
|
||||
if (misses.length < 2) return null;
|
||||
const baseStart = Math.min(...misses.map(({ work }) => work.video.mediaStart));
|
||||
if (!misses.every(({ work }) => isIntegralFrameOffset(work.video.mediaStart - baseStart, fps))) {
|
||||
return null;
|
||||
}
|
||||
if (!windowsOverlapOrTouch(misses, baseStart)) return null;
|
||||
|
||||
const unionEnd = Math.max(
|
||||
...misses.map(({ work }) => work.video.mediaStart + work.videoDuration),
|
||||
);
|
||||
return {
|
||||
groupId,
|
||||
baseStart,
|
||||
unionDuration: unionEnd - baseStart,
|
||||
members: misses.map((miss) => ({
|
||||
miss,
|
||||
offsetFrames: Math.round((miss.work.video.mediaStart - baseStart) * fps),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition one source's misses into overlap-connected components: sort by
|
||||
* window start and cut wherever the next window starts past the running end.
|
||||
* Without this, one disjoint outlier trim (e.g. [100..105] next to three
|
||||
* overlapping trims at [0..11]) fails the union<=sum check for the whole
|
||||
* bucket and every trim falls back to direct extraction.
|
||||
*/
|
||||
function overlapClusters(misses: UniqueExtractionMiss[]): UniqueExtractionMiss[][] {
|
||||
const sorted = [...misses].sort((a, b) => a.work.video.mediaStart - b.work.video.mediaStart);
|
||||
const clusters: UniqueExtractionMiss[][] = [];
|
||||
let current: UniqueExtractionMiss[] = [];
|
||||
let currentEnd = -Infinity;
|
||||
for (const miss of sorted) {
|
||||
const start = miss.work.video.mediaStart;
|
||||
const end = start + miss.work.videoDuration;
|
||||
if (current.length > 0 && start > currentEnd + 1e-9) {
|
||||
clusters.push(current);
|
||||
current = [];
|
||||
currentEnd = -Infinity;
|
||||
}
|
||||
current.push(miss);
|
||||
currentEnd = Math.max(currentEnd, end);
|
||||
}
|
||||
if (current.length > 0) clusters.push(current);
|
||||
return clusters;
|
||||
}
|
||||
|
||||
function planSupersetGroups(
|
||||
misses: UniqueExtractionMiss[],
|
||||
fps: number,
|
||||
): { groups: SupersetGroupPlan[]; direct: UniqueExtractionMiss[] } {
|
||||
const bySource = new Map<string, UniqueExtractionMiss[]>();
|
||||
for (const miss of misses) {
|
||||
const key = supersetGroupingKey(miss.work, fps);
|
||||
bySource.set(key, [...(bySource.get(key) ?? []), miss]);
|
||||
}
|
||||
|
||||
const groups: SupersetGroupPlan[] = [];
|
||||
const direct: UniqueExtractionMiss[] = [];
|
||||
let groupIndex = 0;
|
||||
for (const groupMisses of bySource.values()) {
|
||||
for (const cluster of overlapClusters(groupMisses)) {
|
||||
const group = buildSupersetGroup(`__superset-${groupIndex}`, cluster, fps);
|
||||
if (group) {
|
||||
groups.push(group);
|
||||
groupIndex += 1;
|
||||
} else {
|
||||
direct.push(...cluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { groups, direct };
|
||||
}
|
||||
|
||||
function sliceSupersetMember(
|
||||
member: SupersetMemberPlan,
|
||||
superset: ExtractedFrames,
|
||||
outputDir: string,
|
||||
fps: number,
|
||||
): ExtractedFrames {
|
||||
const { work } = member.miss;
|
||||
rmSync(outputDir, { recursive: true, force: true });
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
// Sample-time correctness: member frame k uses superset frame
|
||||
// offset_i + k, so its source time is
|
||||
// baseStart + (offset_i + k) / fps = mediaStart_i + k / fps.
|
||||
// The frame-alignment precondition is what makes offset_i integral.
|
||||
const requestedFrames = Math.round(work.videoDuration * fps);
|
||||
const availableFrames = Math.max(0, superset.totalFrames - member.offsetFrames);
|
||||
const frameCount = Math.min(requestedFrames, availableFrames);
|
||||
for (let i = 0; i < frameCount; i += 1) {
|
||||
const sourceFrame = superset.framePaths.get(member.offsetFrames + i);
|
||||
if (!sourceFrame) throw new Error(`superset frame ${member.offsetFrames + i} missing`);
|
||||
linkOrCopyFrame(sourceFrame, join(outputDir, frameFileName(i + 1, work.format)));
|
||||
}
|
||||
|
||||
return extractedFramesFromDirectory(work, outputDir, work.videoPath, fps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -701,23 +903,45 @@ export async function extractAllVideoFrames(
|
||||
}
|
||||
}
|
||||
|
||||
async function tryCachedExtract(
|
||||
video: VideoElement,
|
||||
videoPath: string,
|
||||
videoDuration: number,
|
||||
i: number,
|
||||
metadata: VideoMetadata,
|
||||
cacheFormat: CacheFrameFormat,
|
||||
sdrToHdrTransfer?: HdrTransfer,
|
||||
): Promise<ExtractedFrames | null> {
|
||||
if (!cacheRootDir) return null;
|
||||
const keyInput = cacheKeyInputs[i];
|
||||
if (!keyInput) return null;
|
||||
function extractionError(videoId: string, err: unknown): { videoId: string; error: string } {
|
||||
return { videoId, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
||||
type PreparedExtractionResult =
|
||||
| { work: PreparedExtraction }
|
||||
| { error: { videoId: string; error: string } };
|
||||
|
||||
type ExtractionOutcome =
|
||||
| { result: ExtractedFrames }
|
||||
| { error: { videoId: string; error: string } };
|
||||
|
||||
function scopedExtractionOptions(work: PreparedExtraction): ExtractionOptions {
|
||||
return { ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer };
|
||||
}
|
||||
|
||||
function rehydratePublishedCache(work: PreparedExtraction, target: CacheMissTarget) {
|
||||
const rehydrated = rehydrateCacheEntry(target.entry, {
|
||||
videoId: work.video.id,
|
||||
srcPath: target.srcPath,
|
||||
fps: options.fps,
|
||||
format: work.format,
|
||||
metadata: work.metadata,
|
||||
});
|
||||
return { ...rehydrated, ownedByLookup: true };
|
||||
}
|
||||
|
||||
function lookupCacheFor(work: PreparedExtraction): ExtractionOutcome | UniqueExtractionMiss {
|
||||
if (!cacheRootDir) return { work };
|
||||
const keyInput = cacheKeyInputs[work.index];
|
||||
if (!keyInput) return { work };
|
||||
const transform = work.sdrToHdrTransfer
|
||||
? sdrToHdrTransformKey(work.sdrToHdrTransfer)
|
||||
: undefined;
|
||||
|
||||
const keyDuration = resolveSegmentDuration(
|
||||
keyInput.end - keyInput.start,
|
||||
keyInput.mediaStart,
|
||||
metadata,
|
||||
work.metadata,
|
||||
);
|
||||
const lookup = lookupCacheEntry(cacheRootDir, {
|
||||
videoPath: keyInput.videoPath,
|
||||
@@ -726,70 +950,149 @@ export async function extractAllVideoFrames(
|
||||
mediaStart: keyInput.mediaStart,
|
||||
duration: keyDuration,
|
||||
fps: options.fps,
|
||||
format: cacheFormat,
|
||||
transform: sdrToHdrTransfer ? sdrToHdrTransformKey(sdrToHdrTransfer) : undefined,
|
||||
format: work.format,
|
||||
transform,
|
||||
});
|
||||
|
||||
if (lookup.hit) {
|
||||
breakdown.cacheHits += 1;
|
||||
touchCacheEntry(lookup.entry);
|
||||
const rehydrated = rehydrateCacheEntry(lookup.entry, {
|
||||
videoId: video.id,
|
||||
srcPath: keyInput.videoPath,
|
||||
fps: options.fps,
|
||||
format: cacheFormat,
|
||||
metadata,
|
||||
});
|
||||
return { ...rehydrated, ownedByLookup: true };
|
||||
if (!lookup.hit) {
|
||||
breakdown.cacheMisses += 1;
|
||||
return { work, cacheTarget: { entry: lookup.entry, srcPath: keyInput.videoPath } };
|
||||
}
|
||||
|
||||
breakdown.cacheMisses += 1;
|
||||
const partialDir = partialCacheEntryDir(lookup.entry);
|
||||
breakdown.cacheHits += 1;
|
||||
touchCacheEntry(lookup.entry);
|
||||
return {
|
||||
result: rehydratePublishedCache(work, { entry: lookup.entry, srcPath: keyInput.videoPath }),
|
||||
};
|
||||
}
|
||||
|
||||
async function extractDirectMiss(miss: UniqueExtractionMiss): Promise<ExtractedFrames> {
|
||||
const { work, cacheTarget } = miss;
|
||||
if (!cacheTarget) {
|
||||
return extractVideoFramesRange(
|
||||
work.videoPath,
|
||||
work.video.id,
|
||||
work.video.mediaStart,
|
||||
work.videoDuration,
|
||||
scopedExtractionOptions(work),
|
||||
signal,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
const partialDir = partialCacheEntryDir(cacheTarget.entry);
|
||||
mkdirSync(partialDir, { recursive: true });
|
||||
const result = await extractVideoFramesRange(
|
||||
videoPath,
|
||||
video.id,
|
||||
video.mediaStart,
|
||||
videoDuration,
|
||||
{ ...options, format: cacheFormat, sdrToHdrTransfer },
|
||||
work.videoPath,
|
||||
work.video.id,
|
||||
work.video.mediaStart,
|
||||
work.videoDuration,
|
||||
scopedExtractionOptions(work),
|
||||
signal,
|
||||
config,
|
||||
partialDir,
|
||||
);
|
||||
const published = publishCacheEntry(lookup.entry, partialDir);
|
||||
const published = publishCacheEntry(cacheTarget.entry, partialDir);
|
||||
if (!published.published) {
|
||||
breakdown.cachePublishFailures += 1;
|
||||
return { ...result, ownedByLookup: false };
|
||||
}
|
||||
|
||||
const rehydrated = rehydrateCacheEntry(lookup.entry, {
|
||||
videoId: video.id,
|
||||
srcPath: keyInput.videoPath,
|
||||
fps: options.fps,
|
||||
format: cacheFormat,
|
||||
metadata,
|
||||
});
|
||||
return { ...rehydrated, ownedByLookup: true };
|
||||
return rehydratePublishedCache(work, cacheTarget);
|
||||
}
|
||||
|
||||
function extractionError(videoId: string, err: unknown): { videoId: string; error: string } {
|
||||
return { videoId, error: err instanceof Error ? err.message : String(err) };
|
||||
async function executeDirectMiss(miss: UniqueExtractionMiss): Promise<ExtractionOutcome> {
|
||||
try {
|
||||
return { result: await extractDirectMiss(miss) };
|
||||
} catch (err) {
|
||||
return { error: extractionError(miss.work.video.id, err) };
|
||||
}
|
||||
}
|
||||
|
||||
type PreparedExtraction = {
|
||||
video: VideoElement;
|
||||
videoPath: string;
|
||||
index: number;
|
||||
metadata: VideoMetadata;
|
||||
videoDuration: number;
|
||||
format: CacheFrameFormat;
|
||||
sdrToHdrTransfer?: HdrTransfer;
|
||||
dedupeKey: string;
|
||||
};
|
||||
function materializeSupersetMember(
|
||||
member: SupersetMemberPlan,
|
||||
superset: ExtractedFrames,
|
||||
): ExtractedFrames {
|
||||
const { miss } = member;
|
||||
const { work, cacheTarget } = miss;
|
||||
if (!cacheTarget) {
|
||||
return sliceSupersetMember(
|
||||
member,
|
||||
superset,
|
||||
join(options.outputDir, work.video.id),
|
||||
options.fps,
|
||||
);
|
||||
}
|
||||
|
||||
type PreparedExtractionResult =
|
||||
| { work: PreparedExtraction }
|
||||
| { error: { videoId: string; error: string } };
|
||||
const partialDir = partialCacheEntryDir(cacheTarget.entry);
|
||||
const sliced = sliceSupersetMember(member, superset, partialDir, options.fps);
|
||||
const published = publishCacheEntry(cacheTarget.entry, partialDir);
|
||||
if (!published.published) {
|
||||
breakdown.cachePublishFailures += 1;
|
||||
return { ...sliced, ownedByLookup: false };
|
||||
}
|
||||
return rehydratePublishedCache(work, cacheTarget);
|
||||
}
|
||||
|
||||
async function executeSupersetGroup(
|
||||
group: SupersetGroupPlan,
|
||||
): Promise<Array<[string, ExtractionOutcome]>> {
|
||||
const first = group.members[0]?.miss.work;
|
||||
if (!first) return [];
|
||||
// Hardlinks require source and destination on ONE filesystem. Cache-bound
|
||||
// members link into partial dirs under cacheRootDir, which is commonly a
|
||||
// different mount than the render's outputDir — extracting the superset
|
||||
// next to the cache keeps linkSync viable there (the EXDEV copyFileSync
|
||||
// fallback would silently multiply disk usage per member). The
|
||||
// `.partial-` name puts crashed leftovers under the GC's aged-partial
|
||||
// sweep.
|
||||
const tempDir = cacheRootDir
|
||||
? join(cacheRootDir, `${group.groupId}.partial-${process.pid}`)
|
||||
: join(options.outputDir, group.groupId);
|
||||
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
const superset = await extractVideoFramesRange(
|
||||
first.videoPath,
|
||||
group.groupId,
|
||||
group.baseStart,
|
||||
group.unionDuration,
|
||||
scopedExtractionOptions(first),
|
||||
signal,
|
||||
config,
|
||||
tempDir,
|
||||
);
|
||||
const outcomes: Array<[string, ExtractionOutcome]> = [];
|
||||
for (const member of group.members) {
|
||||
outcomes.push([
|
||||
member.miss.work.dedupeKey,
|
||||
{ result: materializeSupersetMember(member, superset) },
|
||||
]);
|
||||
}
|
||||
return outcomes;
|
||||
} catch (err) {
|
||||
// On abort, the union failure is the cancellation itself — re-running
|
||||
// every member through direct extraction would spawn N doomed ffmpeg
|
||||
// processes. Surface the cancellation per member instead.
|
||||
if (signal?.aborted) {
|
||||
return group.members.map((member) => [
|
||||
member.miss.work.dedupeKey,
|
||||
{ error: extractionError(member.miss.work.video.id, err) },
|
||||
]);
|
||||
}
|
||||
const fallback = await Promise.all(
|
||||
group.members.map(
|
||||
async (member) =>
|
||||
[member.miss.work.dedupeKey, await executeDirectMiss(member.miss)] as [
|
||||
string,
|
||||
ExtractionOutcome,
|
||||
],
|
||||
),
|
||||
);
|
||||
return fallback;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const preparedExtractions: PreparedExtractionResult[] = await Promise.all(
|
||||
resolvedVideos.map(async ({ video, videoPath }, index) => {
|
||||
@@ -829,68 +1132,57 @@ export async function extractAllVideoFrames(
|
||||
}),
|
||||
);
|
||||
|
||||
// Value carries the leader's videoId so a shared-extraction failure can be
|
||||
// attributed: N deduped elements otherwise report the same root error under
|
||||
// N different videoIds, which reads as N independent failures in traces.
|
||||
const inFlightExtractions = new Map<
|
||||
string,
|
||||
{ leaderVideoId: string; promise: Promise<ExtractedFrames> }
|
||||
>();
|
||||
const results = await Promise.all(
|
||||
preparedExtractions.map(async (prepared) => {
|
||||
if ("error" in prepared) return prepared;
|
||||
const { work } = prepared;
|
||||
const uniqueWorks = new Map<string, PreparedExtraction>();
|
||||
for (const prepared of preparedExtractions) {
|
||||
if ("work" in prepared && !uniqueWorks.has(prepared.work.dedupeKey)) {
|
||||
uniqueWorks.set(prepared.work.dedupeKey, prepared.work);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = inFlightExtractions.get(work.dedupeKey);
|
||||
if (existing) {
|
||||
try {
|
||||
const shared = await existing.promise;
|
||||
return { result: { ...shared, videoId: work.video.id } };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
error: {
|
||||
videoId: work.video.id,
|
||||
error: `[shared extraction, leader ${existing.leaderVideoId}] ${message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const uniqueOutcomes = new Map<string, ExtractionOutcome>();
|
||||
const cacheMisses: UniqueExtractionMiss[] = [];
|
||||
for (const work of uniqueWorks.values()) {
|
||||
const lookup = lookupCacheFor(work);
|
||||
if ("work" in lookup) {
|
||||
cacheMisses.push(lookup);
|
||||
} else {
|
||||
uniqueOutcomes.set(work.dedupeKey, lookup);
|
||||
}
|
||||
}
|
||||
|
||||
const extraction = (async () => {
|
||||
const cached = await tryCachedExtract(
|
||||
work.video,
|
||||
work.videoPath,
|
||||
work.videoDuration,
|
||||
work.index,
|
||||
work.metadata,
|
||||
work.format,
|
||||
work.sdrToHdrTransfer,
|
||||
);
|
||||
if (cached) return cached;
|
||||
|
||||
return extractVideoFramesRange(
|
||||
work.videoPath,
|
||||
work.video.id,
|
||||
work.video.mediaStart,
|
||||
work.videoDuration,
|
||||
{ ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer },
|
||||
signal,
|
||||
config,
|
||||
);
|
||||
})();
|
||||
|
||||
inFlightExtractions.set(work.dedupeKey, {
|
||||
leaderVideoId: work.video.id,
|
||||
promise: extraction,
|
||||
});
|
||||
return { result: await extraction };
|
||||
} catch (err) {
|
||||
return { error: extractionError(work.video.id, err) };
|
||||
}
|
||||
}),
|
||||
const supersetPlan = planSupersetGroups(cacheMisses, options.fps);
|
||||
const directOutcomes = await Promise.all(
|
||||
supersetPlan.direct.map(
|
||||
async (miss) =>
|
||||
[miss.work.dedupeKey, await executeDirectMiss(miss)] as [string, ExtractionOutcome],
|
||||
),
|
||||
);
|
||||
for (const [key, outcome] of directOutcomes) uniqueOutcomes.set(key, outcome);
|
||||
|
||||
const supersetOutcomes = await Promise.all(
|
||||
supersetPlan.groups.map((group) => executeSupersetGroup(group)),
|
||||
);
|
||||
for (const groupOutcomes of supersetOutcomes) {
|
||||
for (const [key, outcome] of groupOutcomes) uniqueOutcomes.set(key, outcome);
|
||||
}
|
||||
|
||||
const results: ExtractionOutcome[] = preparedExtractions.map((prepared) => {
|
||||
if ("error" in prepared) return prepared;
|
||||
const outcome = uniqueOutcomes.get(prepared.work.dedupeKey);
|
||||
if (!outcome)
|
||||
return { error: extractionError(prepared.work.video.id, "missing extraction result") };
|
||||
if ("error" in outcome) {
|
||||
// A shared (deduped/superset) failure fans out to every element with the
|
||||
// same key; annotate followers with the leader's videoId so N copies of
|
||||
// one root failure are traceable to a single extraction in traces.
|
||||
const isFollower = outcome.error.videoId !== prepared.work.video.id;
|
||||
const message = isFollower
|
||||
? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
|
||||
: outcome.error.error;
|
||||
return { error: { videoId: prepared.work.video.id, error: message } };
|
||||
}
|
||||
return { result: { ...outcome.result, videoId: prepared.work.video.id } };
|
||||
});
|
||||
|
||||
breakdown.extractMs = Date.now() - phase3Start;
|
||||
|
||||
@@ -904,7 +1196,12 @@ export async function extractAllVideoFrames(
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheRootDir) {
|
||||
// Sweep when this render wrote something, plus a staleness fallback so a
|
||||
// 100%-warm workload (misses never > 0) still reclaims space once a day.
|
||||
const sweepDue =
|
||||
breakdown.cacheMisses > 0 ||
|
||||
(cacheRootDir !== undefined && gcSweepDue(cacheRootDir, GC_STALENESS_MS));
|
||||
if (cacheRootDir && sweepDue) {
|
||||
const gcStats = gcExtractionCache(cacheRootDir, {
|
||||
maxBytes: config?.extractCacheMaxBytes ?? DEFAULT_CONFIG.extractCacheMaxBytes,
|
||||
minAgeMs: EXTRACT_CACHE_MIN_AGE_MS,
|
||||
|
||||
Reference in New Issue
Block a user