@hyperframes/engine 0.7.28 → 0.7.30

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.
@@ -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, ensureCacheEntryDir, lookupCacheEntry, markCacheEntryComplete, readKeyStat, rehydrateCacheEntry, } from "./extractionCache.js";
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
- vfFilters.push(`fps=${fps}`);
155
- args.push("-vf", vfFilters.join(","));
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", "6");
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
- reject(new Error(`FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
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
- * Re-encode a VFR (variable frame rate) video segment to CFR so the downstream
320
- * fps filter can extract frames reliably. Screen recordings, phone videos, and
321
- * some webcams emit irregular timestamps that cause two failure modes:
322
- * 1. Output has fewer frames than expected (e.g. -ss 3 -t 4 produces 90
323
- * frames instead of 120 @ 30fps). FrameLookupTable.getFrameAtTime then
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
- async function convertVfrToCfr(inputPath, outputPath, targetFps, startTime, duration, signal, config) {
333
- const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
334
- const args = [
335
- "-ss",
336
- String(startTime),
337
- "-i",
338
- inputPath,
339
- "-t",
340
- String(duration),
341
- "-fps_mode",
342
- "cfr",
343
- "-r",
344
- String(targetFps),
345
- "-c:v",
346
- "libx264",
347
- "-preset",
348
- "fast",
349
- "-crf",
350
- "18",
351
- "-c:a",
352
- "copy",
353
- "-y",
354
- outputPath,
355
- ];
356
- const result = await runFfmpeg(args, { signal, timeout });
357
- if (!result.success) {
358
- throw new Error(`VFR→CFR conversion failed (exit ${result.exitCode}): ${result.stderr.slice(-300)}`);
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
- // Phase 2a/2b preflight. Without this, every render would write a new
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 — convert to the dominant HDR transfer
528
- // so the encoder tags the final video correctly (PQ vs HLG).
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
- // Scope the re-encode to the segment the composition actually uses.
545
- // Long sources (e.g. 30-minute screen recordings) contributing short
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 backwards to
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: Re-encode VFR inputs to CFR so the fps filter in Phase 3 produces
589
- // the expected frame count. Only the used segment is transcoded.
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 (!metadata.isVFR)
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 cacheRootDir = config?.extractCacheDir;
629
- async function tryCachedExtract(video, videoPath, videoDuration, i) {
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 null;
632
- const keyInput = cacheKeyInputs[i];
633
- const probedMeta = videoMetadata[i];
634
- if (!keyInput || !probedMeta)
635
- return null;
636
- const cacheFormat = resolveFrameFormat(probedMeta, options.format);
637
- const keyDuration = resolveSegmentDuration(keyInput.end - keyInput.start, keyInput.mediaStart, probedMeta);
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: cacheFormat,
702
+ format: work.format,
703
+ transform,
646
704
  });
647
- if (lookup.hit) {
648
- breakdown.cacheHits += 1;
649
- const rehydrated = rehydrateCacheEntry(lookup.entry, {
650
- videoId: video.id,
651
- srcPath: keyInput.videoPath,
652
- fps: options.fps,
653
- format: cacheFormat,
654
- metadata: probedMeta,
655
- });
656
- return { ...rehydrated, ownedByLookup: true };
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
- breakdown.cacheMisses += 1;
659
- ensureCacheEntryDir(lookup.entry);
660
- const result = await extractVideoFramesRange(videoPath, video.id, video.mediaStart, videoDuration, { ...options, format: cacheFormat }, signal, config, lookup.entry.dir);
661
- // Mark complete only AFTER frames are on disk — a crash mid-extract
662
- // leaves the entry un-sentineled so the next lookup re-extracts over it.
663
- markCacheEntryComplete(lookup.entry);
664
- return { ...result, ownedByLookup: true };
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
- const results = await Promise.all(resolvedVideos.map(async ({ video, videoPath }, i) => {
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 probedMeta = videoMetadata[i] ?? (await extractMediaMetadata(videoPath));
672
- const videoDuration = resolveSegmentDuration(video.end - video.start, video.mediaStart, probedMeta);
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 cached = await tryCachedExtract(video, videoPath, videoDuration, i);
677
- if (cached)
678
- return { result: cached };
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
- error: {
685
- videoId: video.id,
686
- error: err instanceof Error ? err.message : String(err),
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,