@hyperframes/engine 0.7.27 → 0.7.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +21 -9
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -1
- package/dist/config.js.map +1 -1
- package/dist/services/extractionCache.d.ts +73 -18
- package/dist/services/extractionCache.d.ts.map +1 -1
- package/dist/services/extractionCache.js +254 -22
- package/dist/services/extractionCache.js.map +1 -1
- package/dist/services/frameCapture.d.ts +36 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +56 -18
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts +23 -5
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +392 -200
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Videos are replaced with <img> elements during capture.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "child_process";
|
|
9
|
-
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
|
9
|
+
import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync } from "fs";
|
|
10
10
|
import { isAbsolute, join, posix, resolve, sep } from "path";
|
|
11
11
|
import { parseHTML } from "linkedom";
|
|
12
12
|
import { decodeUrlPathVariants, MEDIA_DURATION_CLAMP_EPSILON_SECONDS } from "@hyperframes/core";
|
|
@@ -14,11 +14,10 @@ import { trackChildProcess } from "../utils/processTracker.js";
|
|
|
14
14
|
import { extractMediaMetadata } from "../utils/ffprobe.js";
|
|
15
15
|
import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
|
|
16
16
|
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
|
17
|
-
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
18
17
|
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
|
19
18
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
20
19
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
21
|
-
import { FRAME_FILENAME_PREFIX,
|
|
20
|
+
import { FRAME_FILENAME_PREFIX, gcExtractionCache, gcSweepDue, lookupCacheEntry, partialCacheEntryDir, publishCacheEntry, readKeyStat, rehydrateCacheEntry, touchCacheEntry, } from "./extractionCache.js";
|
|
22
21
|
/**
|
|
23
22
|
* The single source of truth for the source-video frame-extraction allow-list.
|
|
24
23
|
* The CLI flag parser, the producer HTTP server, and the distributed-config
|
|
@@ -30,6 +29,12 @@ export const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"];
|
|
|
30
29
|
export function isVideoFrameFormat(value) {
|
|
31
30
|
return typeof value === "string" && VIDEO_FRAME_FORMATS.includes(value);
|
|
32
31
|
}
|
|
32
|
+
const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
|
|
33
|
+
const GC_STALENESS_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv";
|
|
35
|
+
function sdrToHdrTransformKey(transfer) {
|
|
36
|
+
return `sdr2hdr-${transfer}`;
|
|
37
|
+
}
|
|
33
38
|
export function parseVideoElements(html) {
|
|
34
39
|
const videos = [];
|
|
35
40
|
const { document } = parseHTML(unwrapTemplate(html));
|
|
@@ -151,11 +156,27 @@ outputDirOverride) {
|
|
|
151
156
|
// VideoToolbox tone-maps during decode; force output to bt709 SDR format
|
|
152
157
|
vfFilters.push("format=nv12");
|
|
153
158
|
}
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
if (!metadata.isVFR) {
|
|
160
|
+
vfFilters.push(`fps=${fps}`);
|
|
161
|
+
}
|
|
162
|
+
if (options.sdrToHdrTransfer) {
|
|
163
|
+
// Ordering intent: fps sampling runs BEFORE the colorspace remap so only
|
|
164
|
+
// kept frames are converted. The remap is pointwise per-frame, so the
|
|
165
|
+
// output is identical either way for the SDR (BT.709, 8-bit) inputs this
|
|
166
|
+
// flag is set for. If format=nv12 (macOS HDR-source decode) ever combines
|
|
167
|
+
// with this flag, revisit: nv12 subsampling before a BT.2020 remap is an
|
|
168
|
+
// untested interaction (today the flags are mutually exclusive — the
|
|
169
|
+
// remap only applies to SDR sources, nv12 only to HDR sources).
|
|
170
|
+
vfFilters.push(SDR_TO_HDR_COLORSPACE_FILTER);
|
|
171
|
+
}
|
|
172
|
+
if (vfFilters.length > 0)
|
|
173
|
+
args.push("-vf", vfFilters.join(","));
|
|
174
|
+
if (metadata.isVFR)
|
|
175
|
+
args.push("-fps_mode", "cfr", "-r", String(fps));
|
|
156
176
|
args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0");
|
|
177
|
+
// Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
|
|
157
178
|
if (format === "png")
|
|
158
|
-
args.push("-compression_level", "
|
|
179
|
+
args.push("-compression_level", "1");
|
|
159
180
|
args.push("-y", outputPattern);
|
|
160
181
|
return new Promise((resolve, reject) => {
|
|
161
182
|
const ffmpeg = spawn(getFfmpegBinary(), args);
|
|
@@ -187,7 +208,14 @@ outputDirOverride) {
|
|
|
187
208
|
return;
|
|
188
209
|
}
|
|
189
210
|
if (code !== 0) {
|
|
190
|
-
|
|
211
|
+
// With the SDR-to-HDR remap folded into this pass, a filter failure
|
|
212
|
+
// (e.g. an ffmpeg built without the colorspace filter) would otherwise
|
|
213
|
+
// surface as a generic extract error and the operator has to grep the
|
|
214
|
+
// filter chain to learn it was the HDR conversion. Attribute it.
|
|
215
|
+
const hdrPrefix = options.sdrToHdrTransfer
|
|
216
|
+
? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
|
|
217
|
+
: "";
|
|
218
|
+
reject(new Error(`${hdrPrefix}FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
|
|
191
219
|
return;
|
|
192
220
|
}
|
|
193
221
|
const framePaths = new Map();
|
|
@@ -221,62 +249,6 @@ outputDirOverride) {
|
|
|
221
249
|
});
|
|
222
250
|
});
|
|
223
251
|
}
|
|
224
|
-
/**
|
|
225
|
-
* Convert an SDR (BT.709) video to BT.2020 wide-gamut so it can be composited
|
|
226
|
-
* alongside HDR content without looking washed out.
|
|
227
|
-
*
|
|
228
|
-
* Uses FFmpeg's `colorspace` filter to remap BT.709 → BT.2020 (no real tone
|
|
229
|
-
* mapping — just a primaries swap so the input fits inside the wider HDR
|
|
230
|
-
* gamut), then re-tags the stream with the caller's target HDR transfer
|
|
231
|
-
* function (PQ for HDR10, HLG for broadcast HDR). The output transfer must
|
|
232
|
-
* match the dominant transfer of the surrounding HDR content; otherwise the
|
|
233
|
-
* downstream encoder will tag the final video with the wrong curve.
|
|
234
|
-
*
|
|
235
|
-
* `startTime` and `duration` bound the re-encode to the segment the composition
|
|
236
|
-
* actually uses. Without them a 30-minute screen recording that contributes a
|
|
237
|
-
* 2-second clip was transcoded in full — a >100× waste for long sources.
|
|
238
|
-
* Mirrors the segment-scope fix already applied to the VFR→CFR preflight.
|
|
239
|
-
*/
|
|
240
|
-
async function convertSdrToHdr(inputPath, outputPath, startTime, duration, targetTransfer, signal, config) {
|
|
241
|
-
// Positive duration is required — FFmpeg's `-t 0` silently produces a 0-byte
|
|
242
|
-
// output that the downstream extractor then treats as a valid (empty) file.
|
|
243
|
-
if (duration <= 0) {
|
|
244
|
-
throw new Error(`convertSdrToHdr: duration must be positive (got ${duration})`);
|
|
245
|
-
}
|
|
246
|
-
const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
247
|
-
// smpte2084 = PQ (HDR10), arib-std-b67 = HLG.
|
|
248
|
-
const colorTrc = targetTransfer === "pq" ? "smpte2084" : "arib-std-b67";
|
|
249
|
-
const args = [
|
|
250
|
-
"-ss",
|
|
251
|
-
String(startTime),
|
|
252
|
-
"-i",
|
|
253
|
-
inputPath,
|
|
254
|
-
"-t",
|
|
255
|
-
String(duration),
|
|
256
|
-
"-vf",
|
|
257
|
-
"colorspace=all=bt2020:iall=bt709:range=tv",
|
|
258
|
-
"-color_primaries",
|
|
259
|
-
"bt2020",
|
|
260
|
-
"-color_trc",
|
|
261
|
-
colorTrc,
|
|
262
|
-
"-colorspace",
|
|
263
|
-
"bt2020nc",
|
|
264
|
-
"-c:v",
|
|
265
|
-
"libx264",
|
|
266
|
-
"-preset",
|
|
267
|
-
"fast",
|
|
268
|
-
"-crf",
|
|
269
|
-
"16",
|
|
270
|
-
"-c:a",
|
|
271
|
-
"copy",
|
|
272
|
-
"-y",
|
|
273
|
-
outputPath,
|
|
274
|
-
];
|
|
275
|
-
const result = await runFfmpeg(args, { signal, timeout });
|
|
276
|
-
if (!result.success) {
|
|
277
|
-
throw new Error(`SDR→HDR conversion failed (exit ${result.exitCode}): ${result.stderr.slice(-300)}`);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
252
|
/**
|
|
281
253
|
* Resolve the used-segment duration for a video, falling back to the source's
|
|
282
254
|
* natural duration when the caller hasn't specified bounds (end=Infinity) or
|
|
@@ -315,48 +287,141 @@ export function resolveFrameFormat(metadata, requested) {
|
|
|
315
287
|
return requested;
|
|
316
288
|
return "jpg";
|
|
317
289
|
}
|
|
290
|
+
function extractedFrameFileNames(outputDir, format) {
|
|
291
|
+
const suffix = `.${format}`;
|
|
292
|
+
return readdirSync(outputDir)
|
|
293
|
+
.filter((file) => file.startsWith(FRAME_FILENAME_PREFIX) && file.endsWith(suffix))
|
|
294
|
+
.sort();
|
|
295
|
+
}
|
|
296
|
+
function extractedFramesFromDirectory(work, outputDir, srcPath, fps) {
|
|
297
|
+
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${work.format}`;
|
|
298
|
+
const framePaths = new Map();
|
|
299
|
+
extractedFrameFileNames(outputDir, work.format).forEach((file, index) => {
|
|
300
|
+
framePaths.set(index, join(outputDir, file));
|
|
301
|
+
});
|
|
302
|
+
return {
|
|
303
|
+
videoId: work.video.id,
|
|
304
|
+
srcPath,
|
|
305
|
+
outputDir,
|
|
306
|
+
framePattern,
|
|
307
|
+
fps,
|
|
308
|
+
totalFrames: framePaths.size,
|
|
309
|
+
metadata: work.metadata,
|
|
310
|
+
framePaths,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function frameFileName(frameNumber, format) {
|
|
314
|
+
return `${FRAME_FILENAME_PREFIX}${String(frameNumber).padStart(5, "0")}.${format}`;
|
|
315
|
+
}
|
|
316
|
+
function linkOrCopyFrame(src, dest) {
|
|
317
|
+
try {
|
|
318
|
+
linkSync(src, dest);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
copyFileSync(src, dest);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function supersetGroupingKey(work, fps) {
|
|
325
|
+
return [work.videoPath, String(fps), work.format, work.sdrToHdrTransfer ?? ""].join("\0");
|
|
326
|
+
}
|
|
327
|
+
function isIntegralFrameOffset(offsetSeconds, fps) {
|
|
328
|
+
const frames = offsetSeconds * fps;
|
|
329
|
+
return Math.abs(frames - Math.round(frames)) <= 1e-4;
|
|
330
|
+
}
|
|
331
|
+
function windowsOverlapOrTouch(misses, baseStart) {
|
|
332
|
+
const unionEnd = Math.max(...misses.map(({ work }) => work.video.mediaStart + work.videoDuration));
|
|
333
|
+
const unionDuration = unionEnd - baseStart;
|
|
334
|
+
const summedDuration = misses.reduce((sum, { work }) => sum + work.videoDuration, 0);
|
|
335
|
+
return unionDuration > 0 && unionDuration <= summedDuration + 1e-9;
|
|
336
|
+
}
|
|
337
|
+
function buildSupersetGroup(groupId, misses, fps) {
|
|
338
|
+
if (misses.length < 2)
|
|
339
|
+
return null;
|
|
340
|
+
const baseStart = Math.min(...misses.map(({ work }) => work.video.mediaStart));
|
|
341
|
+
if (!misses.every(({ work }) => isIntegralFrameOffset(work.video.mediaStart - baseStart, fps))) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (!windowsOverlapOrTouch(misses, baseStart))
|
|
345
|
+
return null;
|
|
346
|
+
const unionEnd = Math.max(...misses.map(({ work }) => work.video.mediaStart + work.videoDuration));
|
|
347
|
+
return {
|
|
348
|
+
groupId,
|
|
349
|
+
baseStart,
|
|
350
|
+
unionDuration: unionEnd - baseStart,
|
|
351
|
+
members: misses.map((miss) => ({
|
|
352
|
+
miss,
|
|
353
|
+
offsetFrames: Math.round((miss.work.video.mediaStart - baseStart) * fps),
|
|
354
|
+
})),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
318
357
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* returns null for late timestamps and the caller freezes on the last
|
|
325
|
-
* valid frame.
|
|
326
|
-
* 2. Large duplicate-frame runs where source PTS don't land on target
|
|
327
|
-
* timestamps.
|
|
328
|
-
*
|
|
329
|
-
* Only the [startTime, startTime+duration] window is re-encoded, so long
|
|
330
|
-
* recordings aren't fully transcoded when only a short clip is used.
|
|
358
|
+
* Partition one source's misses into overlap-connected components: sort by
|
|
359
|
+
* window start and cut wherever the next window starts past the running end.
|
|
360
|
+
* Without this, one disjoint outlier trim (e.g. [100..105] next to three
|
|
361
|
+
* overlapping trims at [0..11]) fails the union<=sum check for the whole
|
|
362
|
+
* bucket and every trim falls back to direct extraction.
|
|
331
363
|
*/
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
364
|
+
function overlapClusters(misses) {
|
|
365
|
+
const sorted = [...misses].sort((a, b) => a.work.video.mediaStart - b.work.video.mediaStart);
|
|
366
|
+
const clusters = [];
|
|
367
|
+
let current = [];
|
|
368
|
+
let currentEnd = -Infinity;
|
|
369
|
+
for (const miss of sorted) {
|
|
370
|
+
const start = miss.work.video.mediaStart;
|
|
371
|
+
const end = start + miss.work.videoDuration;
|
|
372
|
+
if (current.length > 0 && start > currentEnd + 1e-9) {
|
|
373
|
+
clusters.push(current);
|
|
374
|
+
current = [];
|
|
375
|
+
currentEnd = -Infinity;
|
|
376
|
+
}
|
|
377
|
+
current.push(miss);
|
|
378
|
+
currentEnd = Math.max(currentEnd, end);
|
|
379
|
+
}
|
|
380
|
+
if (current.length > 0)
|
|
381
|
+
clusters.push(current);
|
|
382
|
+
return clusters;
|
|
383
|
+
}
|
|
384
|
+
function planSupersetGroups(misses, fps) {
|
|
385
|
+
const bySource = new Map();
|
|
386
|
+
for (const miss of misses) {
|
|
387
|
+
const key = supersetGroupingKey(miss.work, fps);
|
|
388
|
+
bySource.set(key, [...(bySource.get(key) ?? []), miss]);
|
|
389
|
+
}
|
|
390
|
+
const groups = [];
|
|
391
|
+
const direct = [];
|
|
392
|
+
let groupIndex = 0;
|
|
393
|
+
for (const groupMisses of bySource.values()) {
|
|
394
|
+
for (const cluster of overlapClusters(groupMisses)) {
|
|
395
|
+
const group = buildSupersetGroup(`__superset-${groupIndex}`, cluster, fps);
|
|
396
|
+
if (group) {
|
|
397
|
+
groups.push(group);
|
|
398
|
+
groupIndex += 1;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
direct.push(...cluster);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { groups, direct };
|
|
406
|
+
}
|
|
407
|
+
function sliceSupersetMember(member, superset, outputDir, fps) {
|
|
408
|
+
const { work } = member.miss;
|
|
409
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
410
|
+
mkdirSync(outputDir, { recursive: true });
|
|
411
|
+
// Sample-time correctness: member frame k uses superset frame
|
|
412
|
+
// offset_i + k, so its source time is
|
|
413
|
+
// baseStart + (offset_i + k) / fps = mediaStart_i + k / fps.
|
|
414
|
+
// The frame-alignment precondition is what makes offset_i integral.
|
|
415
|
+
const requestedFrames = Math.round(work.videoDuration * fps);
|
|
416
|
+
const availableFrames = Math.max(0, superset.totalFrames - member.offsetFrames);
|
|
417
|
+
const frameCount = Math.min(requestedFrames, availableFrames);
|
|
418
|
+
for (let i = 0; i < frameCount; i += 1) {
|
|
419
|
+
const sourceFrame = superset.framePaths.get(member.offsetFrames + i);
|
|
420
|
+
if (!sourceFrame)
|
|
421
|
+
throw new Error(`superset frame ${member.offsetFrames + i} missing`);
|
|
422
|
+
linkOrCopyFrame(sourceFrame, join(outputDir, frameFileName(i + 1, work.format)));
|
|
359
423
|
}
|
|
424
|
+
return extractedFramesFromDirectory(work, outputDir, work.videoPath, fps);
|
|
360
425
|
}
|
|
361
426
|
/**
|
|
362
427
|
* Resolve a relative `<video src>` to a filesystem path the way the browser
|
|
@@ -418,6 +483,10 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
418
483
|
let totalFramesExtracted = 0;
|
|
419
484
|
const breakdown = {
|
|
420
485
|
resolveMs: 0,
|
|
486
|
+
cachePublishFailures: 0,
|
|
487
|
+
cacheGcEvictions: 0,
|
|
488
|
+
cacheGcBytesFreed: 0,
|
|
489
|
+
cacheAgedPartialsCleared: 0,
|
|
421
490
|
hdrProbeMs: 0,
|
|
422
491
|
hdrPreflightMs: 0,
|
|
423
492
|
hdrPreflightCount: 0,
|
|
@@ -475,8 +544,8 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
475
544
|
breakdown.resolveMs = Date.now() - phase1Start;
|
|
476
545
|
// Snapshot the pre-preflight key inputs so the extraction cache keys on the
|
|
477
546
|
// user-visible source (original path, original mediaStart, original segment
|
|
478
|
-
// bounds) rather than the workDir-local normalized file produced by
|
|
479
|
-
//
|
|
547
|
+
// bounds) rather than the workDir-local normalized file produced by the
|
|
548
|
+
// HDR preflight. Without this, every render would write a new
|
|
480
549
|
// normalized file with a fresh mtime → fresh cache key → perpetual misses.
|
|
481
550
|
const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => {
|
|
482
551
|
const stat = readKeyStat(videoPath);
|
|
@@ -499,6 +568,12 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
499
568
|
const phase2ProbeStart = Date.now();
|
|
500
569
|
const videoMetadata = await Promise.all(resolvedVideos.map(({ videoPath }) => extractMediaMetadata(videoPath)));
|
|
501
570
|
const videoColorSpaces = videoMetadata.map((m) => m.colorSpace);
|
|
571
|
+
// Canonical per-index record of the SDR-to-HDR transform decision. BOTH the
|
|
572
|
+
// cache key (transform discriminator) and the extraction options read from
|
|
573
|
+
// this array via the prepared work items — never set one side independently
|
|
574
|
+
// or cache lookups and written frames drift apart (the poisoning bug this
|
|
575
|
+
// field exists to fix).
|
|
576
|
+
const sdrToHdrTransfers = resolvedVideos.map(() => undefined);
|
|
502
577
|
breakdown.hdrProbeMs = Date.now() - phase2ProbeStart;
|
|
503
578
|
const hdrPreflightStart = Date.now();
|
|
504
579
|
const hdrInfo = analyzeCompositionHdr(videoColorSpaces);
|
|
@@ -517,15 +592,14 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
517
592
|
// for the whole render, and any source not on that curve is normalized to
|
|
518
593
|
// it. If you need both transfers, render two separate compositions.
|
|
519
594
|
const targetTransfer = hdrInfo.dominantTransfer;
|
|
520
|
-
const convertDir = join(options.outputDir, "_hdr_normalized");
|
|
521
|
-
mkdirSync(convertDir, { recursive: true });
|
|
522
595
|
for (let i = 0; i < resolvedVideos.length; i++) {
|
|
523
596
|
if (signal?.aborted)
|
|
524
597
|
break;
|
|
525
598
|
const cs = videoColorSpaces[i] ?? null;
|
|
526
599
|
if (!isHdrColorSpaceUtil(cs)) {
|
|
527
|
-
// SDR video in a mixed timeline —
|
|
528
|
-
// so the encoder tags the final video correctly
|
|
600
|
+
// SDR video in a mixed timeline — extract through a BT.709→BT.2020
|
|
601
|
+
// colorspace filter so the encoder tags the final video correctly
|
|
602
|
+
// (PQ vs HLG) without a separate normalized intermediate.
|
|
529
603
|
const entry = resolvedVideos[i];
|
|
530
604
|
const metadata = videoMetadata[i];
|
|
531
605
|
if (!entry || !metadata)
|
|
@@ -541,37 +615,15 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
541
615
|
hdrSkippedIndices.add(i);
|
|
542
616
|
continue;
|
|
543
617
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
// clips were transcoded in full pre-fix — a >100× waste.
|
|
547
|
-
let segDuration = entry.video.end - entry.video.start;
|
|
548
|
-
if (!Number.isFinite(segDuration) || segDuration <= 0) {
|
|
549
|
-
const sourceRemaining = metadata.durationSeconds - entry.video.mediaStart;
|
|
550
|
-
segDuration = sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
|
|
551
|
-
}
|
|
552
|
-
const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
|
|
553
|
-
try {
|
|
554
|
-
await convertSdrToHdr(entry.videoPath, convertedPath, entry.video.mediaStart, segDuration, targetTransfer, signal, config);
|
|
555
|
-
entry.videoPath = convertedPath;
|
|
556
|
-
// Segment-scoped re-encode starts the new file at t=0, so downstream
|
|
557
|
-
// extraction must seek from 0, not the original mediaStart. Shallow-copy
|
|
558
|
-
// to avoid mutating the caller's VideoElement (mirrors the VFR fix).
|
|
559
|
-
entry.video = { ...entry.video, mediaStart: 0 };
|
|
560
|
-
breakdown.hdrPreflightCount += 1;
|
|
561
|
-
}
|
|
562
|
-
catch (err) {
|
|
563
|
-
errors.push({
|
|
564
|
-
videoId: entry.video.id,
|
|
565
|
-
error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
566
|
-
});
|
|
567
|
-
}
|
|
618
|
+
sdrToHdrTransfers[i] = targetTransfer;
|
|
619
|
+
breakdown.hdrPreflightCount += 1;
|
|
568
620
|
}
|
|
569
621
|
}
|
|
570
622
|
}
|
|
571
623
|
breakdown.hdrPreflightMs = Date.now() - hdrPreflightStart;
|
|
572
624
|
// Remove HDR-preflight-skipped entries from every parallel array so Phase 2b
|
|
573
|
-
// (VFR) and Phase 3 (extract) don't re-process them. Iterate
|
|
574
|
-
// keep indices stable while splicing.
|
|
625
|
+
// (VFR classification) and Phase 3 (extract) don't re-process them. Iterate
|
|
626
|
+
// backwards to keep indices stable while splicing.
|
|
575
627
|
if (hdrSkippedIndices.size > 0) {
|
|
576
628
|
for (let i = resolvedVideos.length - 1; i >= 0; i--) {
|
|
577
629
|
if (hdrSkippedIndices.has(i)) {
|
|
@@ -582,13 +634,13 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
582
634
|
// with the other parallel arrays so Phase 3's `cacheKeyInputs[i]`
|
|
583
635
|
// lookup doesn't point at a stale slot after the splice.
|
|
584
636
|
cacheKeyInputs.splice(i, 1);
|
|
637
|
+
sdrToHdrTransfers.splice(i, 1);
|
|
585
638
|
}
|
|
586
639
|
}
|
|
587
640
|
}
|
|
588
|
-
// Phase 2b:
|
|
589
|
-
//
|
|
641
|
+
// Phase 2b: Keep VFR observability while routing VFR inputs through the
|
|
642
|
+
// one-pass CFR extraction path in Phase 3.
|
|
590
643
|
const vfrPreflightStart = Date.now();
|
|
591
|
-
const vfrNormDir = join(options.outputDir, "_vfr_normalized");
|
|
592
644
|
for (let i = 0; i < resolvedVideos.length; i++) {
|
|
593
645
|
if (signal?.aborted)
|
|
594
646
|
break;
|
|
@@ -598,43 +650,48 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
598
650
|
const vfrProbeStart = Date.now();
|
|
599
651
|
const metadata = await extractMediaMetadata(entry.videoPath);
|
|
600
652
|
breakdown.vfrProbeMs += Date.now() - vfrProbeStart;
|
|
601
|
-
if (
|
|
602
|
-
continue;
|
|
603
|
-
let segDuration = entry.video.end - entry.video.start;
|
|
604
|
-
if (!Number.isFinite(segDuration) || segDuration <= 0) {
|
|
605
|
-
const sourceRemaining = metadata.durationSeconds - entry.video.mediaStart;
|
|
606
|
-
segDuration = sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
|
|
607
|
-
}
|
|
608
|
-
mkdirSync(vfrNormDir, { recursive: true });
|
|
609
|
-
const normalizedPath = join(vfrNormDir, `${entry.video.id}_cfr.mp4`);
|
|
610
|
-
try {
|
|
611
|
-
await convertVfrToCfr(entry.videoPath, normalizedPath, options.fps, entry.video.mediaStart, segDuration, signal, config);
|
|
612
|
-
entry.videoPath = normalizedPath;
|
|
613
|
-
// Segment-scoped re-encode starts the new file at t=0, so downstream
|
|
614
|
-
// extraction must seek from 0, not the original mediaStart. Shallow-copy
|
|
615
|
-
// to avoid mutating the caller's VideoElement.
|
|
616
|
-
entry.video = { ...entry.video, mediaStart: 0 };
|
|
653
|
+
if (metadata.isVFR)
|
|
617
654
|
breakdown.vfrPreflightCount += 1;
|
|
618
|
-
}
|
|
619
|
-
catch (err) {
|
|
620
|
-
errors.push({
|
|
621
|
-
videoId: entry.video.id,
|
|
622
|
-
error: err instanceof Error ? err.message : String(err),
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
655
|
}
|
|
626
656
|
breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart;
|
|
627
657
|
const phase3Start = Date.now();
|
|
628
|
-
const
|
|
629
|
-
|
|
658
|
+
const configuredCacheRootDir = config?.extractCacheDir;
|
|
659
|
+
let cacheRootDir;
|
|
660
|
+
if (configuredCacheRootDir) {
|
|
661
|
+
try {
|
|
662
|
+
mkdirSync(configuredCacheRootDir, { recursive: true });
|
|
663
|
+
cacheRootDir = configuredCacheRootDir;
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
process.stderr.write(`[hyperframes:render] WARNING: extraction cache dir ${configuredCacheRootDir} is not writable; caching disabled for this render\n`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function extractionError(videoId, err) {
|
|
670
|
+
return { videoId, error: err instanceof Error ? err.message : String(err) };
|
|
671
|
+
}
|
|
672
|
+
function scopedExtractionOptions(work) {
|
|
673
|
+
return { ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer };
|
|
674
|
+
}
|
|
675
|
+
function rehydratePublishedCache(work, target) {
|
|
676
|
+
const rehydrated = rehydrateCacheEntry(target.entry, {
|
|
677
|
+
videoId: work.video.id,
|
|
678
|
+
srcPath: target.srcPath,
|
|
679
|
+
fps: options.fps,
|
|
680
|
+
format: work.format,
|
|
681
|
+
metadata: work.metadata,
|
|
682
|
+
});
|
|
683
|
+
return { ...rehydrated, ownedByLookup: true };
|
|
684
|
+
}
|
|
685
|
+
function lookupCacheFor(work) {
|
|
630
686
|
if (!cacheRootDir)
|
|
631
|
-
return
|
|
632
|
-
const keyInput = cacheKeyInputs[
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
687
|
+
return { work };
|
|
688
|
+
const keyInput = cacheKeyInputs[work.index];
|
|
689
|
+
if (!keyInput)
|
|
690
|
+
return { work };
|
|
691
|
+
const transform = work.sdrToHdrTransfer
|
|
692
|
+
? sdrToHdrTransformKey(work.sdrToHdrTransfer)
|
|
693
|
+
: undefined;
|
|
694
|
+
const keyDuration = resolveSegmentDuration(keyInput.end - keyInput.start, keyInput.mediaStart, work.metadata);
|
|
638
695
|
const lookup = lookupCacheEntry(cacheRootDir, {
|
|
639
696
|
videoPath: keyInput.videoPath,
|
|
640
697
|
mtimeMs: keyInput.mtimeMs,
|
|
@@ -642,52 +699,174 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
642
699
|
mediaStart: keyInput.mediaStart,
|
|
643
700
|
duration: keyDuration,
|
|
644
701
|
fps: options.fps,
|
|
645
|
-
format:
|
|
702
|
+
format: work.format,
|
|
703
|
+
transform,
|
|
646
704
|
});
|
|
647
|
-
if (lookup.hit) {
|
|
648
|
-
breakdown.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
705
|
+
if (!lookup.hit) {
|
|
706
|
+
breakdown.cacheMisses += 1;
|
|
707
|
+
return { work, cacheTarget: { entry: lookup.entry, srcPath: keyInput.videoPath } };
|
|
708
|
+
}
|
|
709
|
+
breakdown.cacheHits += 1;
|
|
710
|
+
touchCacheEntry(lookup.entry);
|
|
711
|
+
return {
|
|
712
|
+
result: rehydratePublishedCache(work, { entry: lookup.entry, srcPath: keyInput.videoPath }),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
async function extractDirectMiss(miss) {
|
|
716
|
+
const { work, cacheTarget } = miss;
|
|
717
|
+
if (!cacheTarget) {
|
|
718
|
+
return extractVideoFramesRange(work.videoPath, work.video.id, work.video.mediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config);
|
|
719
|
+
}
|
|
720
|
+
const partialDir = partialCacheEntryDir(cacheTarget.entry);
|
|
721
|
+
mkdirSync(partialDir, { recursive: true });
|
|
722
|
+
const result = await extractVideoFramesRange(work.videoPath, work.video.id, work.video.mediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config, partialDir);
|
|
723
|
+
const published = publishCacheEntry(cacheTarget.entry, partialDir);
|
|
724
|
+
if (!published.published) {
|
|
725
|
+
breakdown.cachePublishFailures += 1;
|
|
726
|
+
return { ...result, ownedByLookup: false };
|
|
727
|
+
}
|
|
728
|
+
return rehydratePublishedCache(work, cacheTarget);
|
|
729
|
+
}
|
|
730
|
+
async function executeDirectMiss(miss) {
|
|
731
|
+
try {
|
|
732
|
+
return { result: await extractDirectMiss(miss) };
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
return { error: extractionError(miss.work.video.id, err) };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function materializeSupersetMember(member, superset) {
|
|
739
|
+
const { miss } = member;
|
|
740
|
+
const { work, cacheTarget } = miss;
|
|
741
|
+
if (!cacheTarget) {
|
|
742
|
+
return sliceSupersetMember(member, superset, join(options.outputDir, work.video.id), options.fps);
|
|
657
743
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
744
|
+
const partialDir = partialCacheEntryDir(cacheTarget.entry);
|
|
745
|
+
const sliced = sliceSupersetMember(member, superset, partialDir, options.fps);
|
|
746
|
+
const published = publishCacheEntry(cacheTarget.entry, partialDir);
|
|
747
|
+
if (!published.published) {
|
|
748
|
+
breakdown.cachePublishFailures += 1;
|
|
749
|
+
return { ...sliced, ownedByLookup: false };
|
|
750
|
+
}
|
|
751
|
+
return rehydratePublishedCache(work, cacheTarget);
|
|
665
752
|
}
|
|
666
|
-
|
|
753
|
+
async function executeSupersetGroup(group) {
|
|
754
|
+
const first = group.members[0]?.miss.work;
|
|
755
|
+
if (!first)
|
|
756
|
+
return [];
|
|
757
|
+
// Hardlinks require source and destination on ONE filesystem. Cache-bound
|
|
758
|
+
// members link into partial dirs under cacheRootDir, which is commonly a
|
|
759
|
+
// different mount than the render's outputDir — extracting the superset
|
|
760
|
+
// next to the cache keeps linkSync viable there (the EXDEV copyFileSync
|
|
761
|
+
// fallback would silently multiply disk usage per member). The
|
|
762
|
+
// `.partial-` name puts crashed leftovers under the GC's aged-partial
|
|
763
|
+
// sweep.
|
|
764
|
+
const tempDir = cacheRootDir
|
|
765
|
+
? join(cacheRootDir, `${group.groupId}.partial-${process.pid}`)
|
|
766
|
+
: join(options.outputDir, group.groupId);
|
|
767
|
+
try {
|
|
768
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
769
|
+
const superset = await extractVideoFramesRange(first.videoPath, group.groupId, group.baseStart, group.unionDuration, scopedExtractionOptions(first), signal, config, tempDir);
|
|
770
|
+
const outcomes = [];
|
|
771
|
+
for (const member of group.members) {
|
|
772
|
+
outcomes.push([
|
|
773
|
+
member.miss.work.dedupeKey,
|
|
774
|
+
{ result: materializeSupersetMember(member, superset) },
|
|
775
|
+
]);
|
|
776
|
+
}
|
|
777
|
+
return outcomes;
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
// On abort, the union failure is the cancellation itself — re-running
|
|
781
|
+
// every member through direct extraction would spawn N doomed ffmpeg
|
|
782
|
+
// processes. Surface the cancellation per member instead.
|
|
783
|
+
if (signal?.aborted) {
|
|
784
|
+
return group.members.map((member) => [
|
|
785
|
+
member.miss.work.dedupeKey,
|
|
786
|
+
{ error: extractionError(member.miss.work.video.id, err) },
|
|
787
|
+
]);
|
|
788
|
+
}
|
|
789
|
+
const fallback = await Promise.all(group.members.map(async (member) => [member.miss.work.dedupeKey, await executeDirectMiss(member.miss)]));
|
|
790
|
+
return fallback;
|
|
791
|
+
}
|
|
792
|
+
finally {
|
|
793
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const preparedExtractions = await Promise.all(resolvedVideos.map(async ({ video, videoPath }, index) => {
|
|
667
797
|
if (signal?.aborted) {
|
|
668
798
|
throw new Error("Video frame extraction cancelled");
|
|
669
799
|
}
|
|
670
800
|
try {
|
|
671
|
-
const
|
|
672
|
-
const videoDuration = resolveSegmentDuration(video.end - video.start, video.mediaStart,
|
|
801
|
+
const metadata = videoMetadata[index] ?? (await extractMediaMetadata(videoPath));
|
|
802
|
+
const videoDuration = resolveSegmentDuration(video.end - video.start, video.mediaStart, metadata);
|
|
673
803
|
if (video.end - video.start !== videoDuration) {
|
|
674
804
|
video.end = video.start + videoDuration;
|
|
675
805
|
}
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const result = await extractVideoFramesRange(videoPath, video.id, video.mediaStart, videoDuration, { ...options, format: resolveFrameFormat(probedMeta, options.format) }, signal, config);
|
|
680
|
-
return { result };
|
|
681
|
-
}
|
|
682
|
-
catch (err) {
|
|
806
|
+
const format = resolveFrameFormat(metadata, options.format);
|
|
807
|
+
const sdrToHdrTransfer = sdrToHdrTransfers[index];
|
|
808
|
+
const dedupeKey = `${videoPath}\0${video.mediaStart}\0${videoDuration}\0${options.fps}\0${format}\0${sdrToHdrTransfer ?? ""}`;
|
|
683
809
|
return {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
810
|
+
work: {
|
|
811
|
+
video,
|
|
812
|
+
videoPath,
|
|
813
|
+
index,
|
|
814
|
+
metadata,
|
|
815
|
+
videoDuration,
|
|
816
|
+
format,
|
|
817
|
+
sdrToHdrTransfer,
|
|
818
|
+
dedupeKey,
|
|
687
819
|
},
|
|
688
820
|
};
|
|
689
821
|
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
return { error: extractionError(video.id, err) };
|
|
824
|
+
}
|
|
690
825
|
}));
|
|
826
|
+
const uniqueWorks = new Map();
|
|
827
|
+
for (const prepared of preparedExtractions) {
|
|
828
|
+
if ("work" in prepared && !uniqueWorks.has(prepared.work.dedupeKey)) {
|
|
829
|
+
uniqueWorks.set(prepared.work.dedupeKey, prepared.work);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const uniqueOutcomes = new Map();
|
|
833
|
+
const cacheMisses = [];
|
|
834
|
+
for (const work of uniqueWorks.values()) {
|
|
835
|
+
const lookup = lookupCacheFor(work);
|
|
836
|
+
if ("work" in lookup) {
|
|
837
|
+
cacheMisses.push(lookup);
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
uniqueOutcomes.set(work.dedupeKey, lookup);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const supersetPlan = planSupersetGroups(cacheMisses, options.fps);
|
|
844
|
+
const directOutcomes = await Promise.all(supersetPlan.direct.map(async (miss) => [miss.work.dedupeKey, await executeDirectMiss(miss)]));
|
|
845
|
+
for (const [key, outcome] of directOutcomes)
|
|
846
|
+
uniqueOutcomes.set(key, outcome);
|
|
847
|
+
const supersetOutcomes = await Promise.all(supersetPlan.groups.map((group) => executeSupersetGroup(group)));
|
|
848
|
+
for (const groupOutcomes of supersetOutcomes) {
|
|
849
|
+
for (const [key, outcome] of groupOutcomes)
|
|
850
|
+
uniqueOutcomes.set(key, outcome);
|
|
851
|
+
}
|
|
852
|
+
const results = preparedExtractions.map((prepared) => {
|
|
853
|
+
if ("error" in prepared)
|
|
854
|
+
return prepared;
|
|
855
|
+
const outcome = uniqueOutcomes.get(prepared.work.dedupeKey);
|
|
856
|
+
if (!outcome)
|
|
857
|
+
return { error: extractionError(prepared.work.video.id, "missing extraction result") };
|
|
858
|
+
if ("error" in outcome) {
|
|
859
|
+
// A shared (deduped/superset) failure fans out to every element with the
|
|
860
|
+
// same key; annotate followers with the leader's videoId so N copies of
|
|
861
|
+
// one root failure are traceable to a single extraction in traces.
|
|
862
|
+
const isFollower = outcome.error.videoId !== prepared.work.video.id;
|
|
863
|
+
const message = isFollower
|
|
864
|
+
? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
|
|
865
|
+
: outcome.error.error;
|
|
866
|
+
return { error: { videoId: prepared.work.video.id, error: message } };
|
|
867
|
+
}
|
|
868
|
+
return { result: { ...outcome.result, videoId: prepared.work.video.id } };
|
|
869
|
+
});
|
|
691
870
|
breakdown.extractMs = Date.now() - phase3Start;
|
|
692
871
|
// Collect results and errors
|
|
693
872
|
for (const item of results) {
|
|
@@ -699,6 +878,19 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
699
878
|
totalFramesExtracted += item.result.totalFrames;
|
|
700
879
|
}
|
|
701
880
|
}
|
|
881
|
+
// Sweep when this render wrote something, plus a staleness fallback so a
|
|
882
|
+
// 100%-warm workload (misses never > 0) still reclaims space once a day.
|
|
883
|
+
const sweepDue = breakdown.cacheMisses > 0 ||
|
|
884
|
+
(cacheRootDir !== undefined && gcSweepDue(cacheRootDir, GC_STALENESS_MS));
|
|
885
|
+
if (cacheRootDir && sweepDue) {
|
|
886
|
+
const gcStats = gcExtractionCache(cacheRootDir, {
|
|
887
|
+
maxBytes: config?.extractCacheMaxBytes ?? DEFAULT_CONFIG.extractCacheMaxBytes,
|
|
888
|
+
minAgeMs: EXTRACT_CACHE_MIN_AGE_MS,
|
|
889
|
+
});
|
|
890
|
+
breakdown.cacheGcEvictions = gcStats.evictedEntries;
|
|
891
|
+
breakdown.cacheGcBytesFreed = gcStats.evictedBytes;
|
|
892
|
+
breakdown.cacheAgedPartialsCleared = gcStats.agedPartialsRemoved;
|
|
893
|
+
}
|
|
702
894
|
return {
|
|
703
895
|
success: errors.length === 0,
|
|
704
896
|
extracted,
|