@hyperframes/engine 0.7.89 → 0.7.92

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.
@@ -8,9 +8,9 @@
8
8
  import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync } from "fs";
9
9
  import { isAbsolute, join, posix, resolve, sep } from "path";
10
10
  import { parseHTML } from "linkedom";
11
- import { decodeUrlPathVariants, MEDIA_DURATION_CLAMP_EPSILON_SECONDS } from "@hyperframes/core";
11
+ import { decodeUrlPathVariants, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, toFps, } from "@hyperframes/core";
12
12
  import { resolveReferencedStart } from "./referenceResolver.js";
13
- import { extractMediaMetadata } from "../utils/ffprobe.js";
13
+ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, } from "../utils/ffprobe.js";
14
14
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
15
15
  import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js";
16
16
  import { runFfmpeg } from "../utils/runFfmpeg.js";
@@ -28,6 +28,75 @@ export const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"];
28
28
  export function isVideoFrameFormat(value) {
29
29
  return typeof value === "string" && VIDEO_FRAME_FORMATS.includes(value);
30
30
  }
31
+ /**
32
+ * Resolve the frame count produced for a requested extraction duration.
33
+ *
34
+ * CFR extraction uses FFmpeg's fps filter, whose end boundary rounds to the
35
+ * nearest frame. The VFR path normalizes with `-fps_mode cfr -r`, whose end
36
+ * boundary rounds up. Keep this calculation shared by superset slicing and
37
+ * producer coverage accounting so a complete VFR extraction cannot be
38
+ * rejected because the two paths disagree by one frame.
39
+ */
40
+ export function extractionFrameCountForDuration(durationSeconds, fps, isVFR) {
41
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0)
42
+ return 0;
43
+ // FFmpeg receives `String(durationSeconds)` and parses at microsecond
44
+ // precision. Derive the integer microseconds from that same decimal text:
45
+ // multiplying the binary float first is not equivalent (`2.05 * 1e6` is
46
+ // 2049999.9999999998 in JS and would incorrectly truncate one microsecond).
47
+ const serialized = String(durationSeconds).toLowerCase();
48
+ const decimal = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/.exec(serialized);
49
+ if (!decimal)
50
+ return 0;
51
+ const whole = decimal[1] ?? "0";
52
+ const fraction = decimal[2] ?? "";
53
+ const exponent = Number.parseInt(decimal[3] ?? "0", 10);
54
+ const digits = BigInt(`${whole}${fraction}`);
55
+ const microsecondScale = exponent + 6 - fraction.length;
56
+ const microseconds = microsecondScale >= 0
57
+ ? digits * 10n ** BigInt(microsecondScale)
58
+ : digits / 10n ** BigInt(-microsecondScale);
59
+ // Keep the frame-boundary calculation rational too. Converting the exact
60
+ // microseconds back to a binary float recreates the same problem at .5-frame
61
+ // boundaries (`2.05 * 30` is 61.49999999999999 in JS).
62
+ let fpsNumerator;
63
+ let fpsDenominator;
64
+ if (typeof fps === "object") {
65
+ if (!Number.isSafeInteger(fps.num) ||
66
+ !Number.isSafeInteger(fps.den) ||
67
+ fps.num <= 0 ||
68
+ fps.den <= 0) {
69
+ return 0;
70
+ }
71
+ fpsNumerator = BigInt(fps.num);
72
+ fpsDenominator = BigInt(fps.den);
73
+ }
74
+ else {
75
+ if (!Number.isFinite(fps) || fps <= 0)
76
+ return 0;
77
+ // Number-only callers retain their decimal FFmpeg argument exactly. The
78
+ // production render path supplies Fps, so NTSC rates never round-trip
79
+ // through `String(30000 / 1001)` here.
80
+ const serializedFps = String(fps).toLowerCase();
81
+ const fpsDecimal = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/.exec(serializedFps);
82
+ if (!fpsDecimal)
83
+ return 0;
84
+ const fpsWhole = fpsDecimal[1] ?? "0";
85
+ const fpsFraction = fpsDecimal[2] ?? "";
86
+ const fpsExponent = Number.parseInt(fpsDecimal[3] ?? "0", 10);
87
+ const fpsDigits = BigInt(`${fpsWhole}${fpsFraction}`);
88
+ const fpsScale = fpsExponent - fpsFraction.length;
89
+ fpsNumerator = fpsScale >= 0 ? fpsDigits * 10n ** BigInt(fpsScale) : fpsDigits;
90
+ fpsDenominator = fpsScale >= 0 ? 1n : 10n ** BigInt(-fpsScale);
91
+ }
92
+ const frameNumerator = microseconds * fpsNumerator;
93
+ const frameDenominator = 1000000n * fpsDenominator;
94
+ const frameCount = isVFR
95
+ ? (frameNumerator + frameDenominator - 1n) / frameDenominator
96
+ : (2n * frameNumerator + frameDenominator) / (2n * frameDenominator);
97
+ const frames = Number(frameCount);
98
+ return Math.max(1, Number.isSafeInteger(frames) ? frames : Number.MAX_SAFE_INTEGER);
99
+ }
31
100
  const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
32
101
  const GC_STALENESS_MS = 24 * 60 * 60 * 1000;
33
102
  const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv";
@@ -180,7 +249,10 @@ export function parseVideoElements(html) {
180
249
  // reference; the resolver handles both.
181
250
  const start = startAttr ? resolveReferencedStart(document, el, startCache, visiting) : 0;
182
251
  // Derive end from data-end → data-start+data-duration → Infinity (natural duration).
183
- // The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
252
+ // Static compilation cannot always clamp root media because GSAP may supply
253
+ // the root duration at runtime. The producer passes the resolved timeline
254
+ // end into frame extraction, which caps the source duration only after the
255
+ // natural duration is known without rewriting authored timing metadata.
184
256
  let end = 0;
185
257
  if (endAttr) {
186
258
  end = parseFloat(endAttr);
@@ -247,7 +319,10 @@ export async function extractVideoFramesRange(videoPath, videoId, startTime, dur
247
319
  */
248
320
  outputDirOverride) {
249
321
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
250
- const { fps, outputDir, quality = 95 } = options;
322
+ const { outputDir, quality = 95 } = options;
323
+ const normalizedFps = toFps(options.fps);
324
+ const fps = fpsToNumber(normalizedFps);
325
+ const ffmpegFps = fpsToFfmpegArg(normalizedFps);
251
326
  const videoOutputDir = outputDirOverride ?? join(outputDir, videoId);
252
327
  if (!existsSync(videoOutputDir))
253
328
  mkdirSync(videoOutputDir, { recursive: true });
@@ -258,11 +333,12 @@ outputDirOverride) {
258
333
  catch (error) {
259
334
  throw classifyVideoExtractionError(error);
260
335
  }
261
- if (!(metadata.durationSeconds > 0)) {
262
- throw new VideoSourceExtractionError("invalid_media", false, "Video source has no positive duration", `Video source duration is ${metadata.durationSeconds}s`);
336
+ const playableDuration = resolvePlayableVideoDuration(metadata);
337
+ if (!(playableDuration > 0)) {
338
+ throw new VideoSourceExtractionError("invalid_media", false, "Video source has no positive duration", `Playable video stream duration is ${playableDuration}s`);
263
339
  }
264
- if (startTime >= metadata.durationSeconds) {
265
- throw new VideoSourceExtractionError("media_start_out_of_range", false, "Video media start is outside the source duration", `Video media start ${startTime}s is outside source duration ${metadata.durationSeconds}s`);
340
+ if (startTime >= playableDuration) {
341
+ throw new VideoSourceExtractionError("media_start_out_of_range", false, "Video media start is outside the source duration", `Video media start ${startTime}s is outside playable video duration ${playableDuration}s`);
266
342
  }
267
343
  const format = resolveFrameFormat(metadata, options.format);
268
344
  const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
@@ -289,14 +365,23 @@ outputDirOverride) {
289
365
  if (codecMayHaveAlpha(metadata.videoCodec)) {
290
366
  args.push("-c:v", decoderForCodec(metadata.videoCodec));
291
367
  }
292
- args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
368
+ if (options.finalFrameOnly) {
369
+ // Output-side seek decodes from the start before selecting the final
370
+ // sample. This is intentionally reserved for the one-frame path: input
371
+ // seeking is faster, but valid unindexed transports (notably MPEG-TS with
372
+ // a negative timestamp base) can seek to EOF and emit zero frames.
373
+ args.push("-i", videoPath, "-ss", String(startTime), "-frames:v", "1");
374
+ }
375
+ else {
376
+ args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
377
+ }
293
378
  const vfFilters = [];
294
379
  if (isHdr && isMacOS) {
295
380
  // VideoToolbox tone-maps during decode; force output to bt709 SDR format
296
381
  vfFilters.push("format=nv12");
297
382
  }
298
- if (!metadata.isVFR) {
299
- vfFilters.push(`fps=${fps}`);
383
+ if (!options.finalFrameOnly && !metadata.isVFR) {
384
+ vfFilters.push(`fps=${ffmpegFps}`);
300
385
  }
301
386
  if (options.sdrToHdrTransfer) {
302
387
  // Ordering intent: fps sampling runs BEFORE the colorspace remap so only
@@ -310,8 +395,9 @@ outputDirOverride) {
310
395
  }
311
396
  if (vfFilters.length > 0)
312
397
  args.push("-vf", vfFilters.join(","));
313
- if (metadata.isVFR)
314
- args.push("-fps_mode", "cfr", "-r", String(fps));
398
+ if (!options.finalFrameOnly && metadata.isVFR) {
399
+ args.push("-fps_mode", "cfr", "-r", ffmpegFps);
400
+ }
315
401
  args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0");
316
402
  // Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
317
403
  if (format === "png")
@@ -384,11 +470,149 @@ export function classifyFfmpegSpawnError(error, stderr = "") {
384
470
  * natural duration when the caller hasn't specified bounds (end=Infinity) or
385
471
  * the bounds are nonsensical (end<=start).
386
472
  */
387
- function resolveSegmentDuration(requested, mediaStart, metadata) {
473
+ function resolveSegmentDuration(requested, mediaStart, sourceDuration) {
388
474
  if (Number.isFinite(requested) && requested > 0)
389
475
  return requested;
390
- const sourceRemaining = metadata.durationSeconds - mediaStart;
391
- return sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
476
+ const sourceRemaining = sourceDuration - mediaStart;
477
+ return sourceRemaining > 0 ? sourceRemaining : sourceDuration;
478
+ }
479
+ /**
480
+ * Return the range that can actually produce video frames.
481
+ *
482
+ * Container duration may include a longer audio stream or mux padding. Using
483
+ * it for video extraction planning can reserve raw-frame scratch for seconds
484
+ * where no video frames exist. `extractMediaMetadata` already falls back to
485
+ * the container duration when ffprobe omits the stream duration; keep the
486
+ * explicit fallback here for callers supplying older/manual metadata.
487
+ */
488
+ export function resolvePlayableVideoDuration(metadata) {
489
+ return Number.isFinite(metadata.videoStreamDurationSeconds) &&
490
+ metadata.videoStreamDurationSeconds > 0
491
+ ? metadata.videoStreamDurationSeconds
492
+ : metadata.durationSeconds;
493
+ }
494
+ // Logical duration assigned to a one-frame held-tail representation. This is
495
+ // deliberately below any supported output frame interval: coverage expects
496
+ // one frame, while FFmpeg seeks to the separately probed real frame timestamp.
497
+ const FINAL_FRAME_LOGICAL_DURATION_SECONDS = 1e-6;
498
+ /**
499
+ * Intersect an authored slot with the render timeline, then select the
500
+ * smallest playable source range that preserves timeline lookup semantics.
501
+ *
502
+ * A finite authored slot can outlive the source. In that case FFmpeg should
503
+ * still extract at most one source range: lookup either wraps that range for
504
+ * loops or holds its final frame for non-looping video. Keeping the authored
505
+ * timeline origin separate from the extracted range is what makes both
506
+ * behaviours survive the source-duration cap.
507
+ */
508
+ export function resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd, sourceDuration) {
509
+ if (timelineEnd === undefined) {
510
+ return {
511
+ compositionStart: video.start,
512
+ mediaStart: video.mediaStart,
513
+ durationSeconds: resolvedDuration,
514
+ };
515
+ }
516
+ if (!Number.isFinite(timelineEnd)) {
517
+ throw new Error(`Video extraction timelineEnd must be finite; got ${String(timelineEnd)}`);
518
+ }
519
+ const compositionStart = Math.max(0, video.start);
520
+ const trimmedPreroll = compositionStart - video.start;
521
+ const timelineDuration = Math.max(0, timelineEnd - compositionStart);
522
+ // Infinity means "natural source duration", not an authored infinite slot.
523
+ // Explicit finite slots may outlive the source (loop or held tail), while an
524
+ // omitted duration remains source-bounded exactly like the browser runtime.
525
+ const resolvedVisibleDuration = resolvedDuration - trimmedPreroll;
526
+ const visibleDuration = Math.max(0, Math.min(resolvedVisibleDuration, timelineDuration));
527
+ let mediaStart = video.mediaStart + trimmedPreroll;
528
+ if (visibleDuration > 0 && sourceDuration !== undefined) {
529
+ const sourceRemaining = Math.max(0, sourceDuration - video.mediaStart);
530
+ if (sourceRemaining > 0 && video.loop && Number.isFinite(video.end)) {
531
+ const phaseOffset = trimmedPreroll % sourceRemaining;
532
+ const phaseRemaining = sourceRemaining - phaseOffset;
533
+ // The element visibility contract includes its end boundary. Preserve a
534
+ // complete cycle on equality as well, otherwise a rebased suffix would
535
+ // wrap to its own first frame instead of the source cycle's first frame.
536
+ if (visibleDuration >= phaseRemaining) {
537
+ return {
538
+ compositionStart: video.start,
539
+ mediaStart: video.mediaStart,
540
+ durationSeconds: sourceRemaining,
541
+ preserveTimelinePhase: true,
542
+ };
543
+ }
544
+ mediaStart = video.mediaStart + phaseOffset;
545
+ }
546
+ else if (sourceRemaining > 0) {
547
+ const sourceVisibleAfterPreroll = Math.max(0, sourceRemaining - trimmedPreroll);
548
+ if (visibleDuration <= sourceVisibleAfterPreroll) {
549
+ return {
550
+ compositionStart,
551
+ mediaStart,
552
+ durationSeconds: visibleDuration,
553
+ };
554
+ }
555
+ // The visible interval enters (or is entirely inside) the held tail.
556
+ // Extract the visible source suffix. If preroll is already at/past the
557
+ // final decoded timestamp, the async resolver below replaces this tiny
558
+ // provisional suffix with one exact final frame.
559
+ const extractionDuration = Math.min(sourceRemaining, Math.max(sourceVisibleAfterPreroll, FINAL_FRAME_LOGICAL_DURATION_SECONDS));
560
+ const extractionOffset = sourceRemaining - extractionDuration;
561
+ return {
562
+ compositionStart: video.start + extractionOffset,
563
+ mediaStart: video.mediaStart + extractionOffset,
564
+ durationSeconds: extractionDuration,
565
+ preserveTimelineEnd: true,
566
+ ensureFinalFrame: true,
567
+ };
568
+ }
569
+ }
570
+ return {
571
+ compositionStart,
572
+ mediaStart,
573
+ durationSeconds: visibleDuration,
574
+ };
575
+ }
576
+ /**
577
+ * Replace a held-tail suffix that starts at/after the final decoded timestamp
578
+ * with one exact frame. This keeps raw HDR scratch O(one frame) without
579
+ * assuming a one-second seek window contains a CFR/VFR timestamp.
580
+ */
581
+ export async function resolveFinalFrameExtractionWindow(videoPath, video, metadata, window, signal) {
582
+ if (!window.ensureFinalFrame)
583
+ return window;
584
+ const playableDuration = resolvePlayableVideoDuration(metadata);
585
+ const finalFrameTimestamp = await extractFinalVideoFrameTimestamp(videoPath, {
586
+ videoStreamDurationSeconds: playableDuration,
587
+ videoStreamStartSeconds: metadata.videoStreamStartSeconds,
588
+ }, signal);
589
+ if (window.mediaStart < finalFrameTimestamp - 1e-9)
590
+ return window;
591
+ const sourceRemaining = playableDuration - video.mediaStart;
592
+ const logicalDuration = Math.min(sourceRemaining, FINAL_FRAME_LOGICAL_DURATION_SECONDS);
593
+ return {
594
+ compositionStart: Math.max(0, video.start),
595
+ mediaStart: playableDuration - logicalDuration,
596
+ extractionMediaStart: finalFrameTimestamp,
597
+ durationSeconds: logicalDuration,
598
+ preserveTimelineEnd: true,
599
+ finalFrameOnly: true,
600
+ };
601
+ }
602
+ /** Resolve source duration first, then intersect it with the render timeline. */
603
+ export function resolveVideoExtractionWindow(video, metadata, timelineEnd) {
604
+ const playableDuration = resolvePlayableVideoDuration(metadata);
605
+ if (!(playableDuration > 0)) {
606
+ throw new VideoSourceExtractionError("invalid_media", false, "Video source has no positive duration", `Playable video stream duration is ${playableDuration}s`);
607
+ }
608
+ if (video.mediaStart >= playableDuration) {
609
+ throw new VideoSourceExtractionError("media_start_out_of_range", false, "Video media start is outside the source duration", `Video media start ${video.mediaStart}s is outside playable video duration ${playableDuration}s`);
610
+ }
611
+ const resolvedDuration = resolveSegmentDuration(video.end - video.start, video.mediaStart, playableDuration);
612
+ return resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd, playableDuration);
613
+ }
614
+ export function resolveVideoExtractionDuration(video, metadata, timelineEnd) {
615
+ return resolveVideoExtractionWindow(video, metadata, timelineEnd).durationSeconds;
392
616
  }
393
617
  /**
394
618
  * Codecs whose bitstream is allowed to carry an alpha channel. Default the
@@ -452,7 +676,13 @@ function linkOrCopyFrame(src, dest) {
452
676
  }
453
677
  }
454
678
  function supersetGroupingKey(work, fps) {
455
- return [work.videoPath, String(fps), work.format, work.sdrToHdrTransfer ?? ""].join("\0");
679
+ return [
680
+ work.videoPath,
681
+ String(fps),
682
+ work.format,
683
+ work.sdrToHdrTransfer ?? "",
684
+ work.finalFrameOnly ? "final" : "range",
685
+ ].join("\0");
456
686
  }
457
687
  function isIntegralFrameOffset(offsetSeconds, fps) {
458
688
  const frames = offsetSeconds * fps;
@@ -467,6 +697,15 @@ function windowsOverlapOrTouch(misses, baseStart) {
467
697
  function buildSupersetGroup(groupId, misses, fps) {
468
698
  if (misses.length < 2)
469
699
  return null;
700
+ if (misses.some(({ work }) => work.finalFrameOnly))
701
+ return null;
702
+ // VFR normalization (`-fps_mode cfr -r`) establishes its duplicate/drop
703
+ // phase relative to each seek. A union extraction therefore cannot be
704
+ // sliced into the same frames as independently sought member ranges, even
705
+ // when their offsets land on an integral output-frame boundary. Keep VFR
706
+ // ranges direct until the extractor has a proven absolute timestamp phase.
707
+ if (misses.some(({ work }) => work.metadata.isVFR))
708
+ return null;
470
709
  const baseStart = Math.min(...misses.map(({ work }) => work.video.mediaStart));
471
710
  if (!misses.every(({ work }) => isIntegralFrameOffset(work.video.mediaStart - baseStart, fps))) {
472
711
  return null;
@@ -534,7 +773,7 @@ function planSupersetGroups(misses, fps) {
534
773
  }
535
774
  return { groups, direct };
536
775
  }
537
- function sliceSupersetMember(member, superset, outputDir, fps) {
776
+ function sliceSupersetMember(member, superset, outputDir, fps, configuredFps) {
538
777
  const { work } = member.miss;
539
778
  rmSync(outputDir, { recursive: true, force: true });
540
779
  mkdirSync(outputDir, { recursive: true });
@@ -542,7 +781,7 @@ function sliceSupersetMember(member, superset, outputDir, fps) {
542
781
  // offset_i + k, so its source time is
543
782
  // baseStart + (offset_i + k) / fps = mediaStart_i + k / fps.
544
783
  // The frame-alignment precondition is what makes offset_i integral.
545
- const requestedFrames = Math.round(work.videoDuration * fps);
784
+ const requestedFrames = extractionFrameCountForDuration(work.videoDuration, configuredFps, work.metadata.isVFR);
546
785
  const availableFrames = Math.max(0, superset.totalFrames - member.offsetFrames);
547
786
  const frameCount = Math.min(requestedFrames, availableFrames);
548
787
  for (let i = 0; i < frameCount; i += 1) {
@@ -612,6 +851,12 @@ export function resolveProjectRelativeSrc(src, baseDir, compiledDir) {
612
851
  return candidates.find(existsSync) ?? join(baseDir, cleanSrc);
613
852
  }
614
853
  export async function extractAllVideoFrames(videos, baseDir, options, signal, config, compiledDir) {
854
+ if (options.timelineEnd !== undefined && !Number.isFinite(options.timelineEnd)) {
855
+ throw new Error(`Video extraction timelineEnd must be finite; got ${String(options.timelineEnd)}`);
856
+ }
857
+ const configuredFps = toFps(options.fps);
858
+ const fps = fpsToNumber(configuredFps);
859
+ const fpsKey = fpsToFfmpegArg(configuredFps);
615
860
  const startTime = Date.now();
616
861
  const extracted = [];
617
862
  const errors = [];
@@ -645,6 +890,8 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
645
890
  for (const video of videos) {
646
891
  if (signal?.aborted)
647
892
  break;
893
+ if (options.timelineEnd !== undefined && video.start >= options.timelineEnd)
894
+ continue;
648
895
  try {
649
896
  let videoPath = video.src;
650
897
  if (!isHttpUrl(videoPath)) {
@@ -689,10 +936,11 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
689
936
  }
690
937
  breakdown.resolveMs = Date.now() - phase1Start;
691
938
  // Snapshot the pre-preflight key inputs so the extraction cache keys on the
692
- // user-visible source (original path, original mediaStart, original segment
693
- // bounds) rather than the workDir-local normalized file produced by the
939
+ // user-visible source path rather than the
940
+ // workDir-local normalized file produced by the
694
941
  // HDR preflight. Without this, every render would write a new
695
942
  // normalized file with a fresh mtime → fresh cache key → perpetual misses.
943
+ // Phase 3 updates mediaStart after trimming any invisible negative preroll.
696
944
  const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => {
697
945
  const stat = readKeyStat(videoPath);
698
946
  // Missing files return null — skip the cache path for that entry. The
@@ -706,8 +954,6 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
706
954
  mtimeMs: stat.mtimeMs,
707
955
  size: stat.size,
708
956
  mediaStart: video.mediaStart,
709
- start: video.start,
710
- end: video.end,
711
957
  };
712
958
  });
713
959
  // Phase 2: Probe color spaces and normalize if mixed HDR/SDR
@@ -783,12 +1029,13 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
783
1029
  // Guard against mediaStart past EOF — FFmpeg's `-ss` silently produces
784
1030
  // a 0-byte file when seeking beyond the source duration, and the
785
1031
  // downstream extractor then points at a broken input.
786
- if (entry.video.mediaStart >= metadata.durationSeconds) {
1032
+ const playableDuration = resolvePlayableVideoDuration(metadata);
1033
+ if (entry.video.mediaStart >= playableDuration) {
787
1034
  errors.push({
788
1035
  videoId: entry.video.id,
789
1036
  kind: "media_start_out_of_range",
790
1037
  retryable: false,
791
- error: `SDR→HDR conversion skipped: mediaStart (${entry.video.mediaStart}s) ≥ source duration (${metadata.durationSeconds}s)`,
1038
+ error: `SDR→HDR conversion skipped: mediaStart (${entry.video.mediaStart}s) ≥ playable video duration (${playableDuration}s)`,
792
1039
  });
793
1040
  hdrSkippedIndices.add(i);
794
1041
  continue;
@@ -851,13 +1098,18 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
851
1098
  };
852
1099
  }
853
1100
  function scopedExtractionOptions(work) {
854
- return { ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer };
1101
+ return {
1102
+ ...options,
1103
+ format: work.format,
1104
+ sdrToHdrTransfer: work.sdrToHdrTransfer,
1105
+ finalFrameOnly: work.finalFrameOnly,
1106
+ };
855
1107
  }
856
1108
  function rehydratePublishedCache(work, target) {
857
1109
  const rehydrated = rehydrateCacheEntry(target.entry, {
858
1110
  videoId: work.video.id,
859
1111
  srcPath: target.srcPath,
860
- fps: options.fps,
1112
+ fps,
861
1113
  format: work.format,
862
1114
  metadata: work.metadata,
863
1115
  });
@@ -869,17 +1121,18 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
869
1121
  const keyInput = cacheKeyInputs[work.index];
870
1122
  if (!keyInput)
871
1123
  return { work };
872
- const transform = work.sdrToHdrTransfer
873
- ? sdrToHdrTransformKey(work.sdrToHdrTransfer)
874
- : undefined;
875
- const keyDuration = resolveSegmentDuration(keyInput.end - keyInput.start, keyInput.mediaStart, work.metadata);
1124
+ const transformParts = [
1125
+ work.sdrToHdrTransfer ? sdrToHdrTransformKey(work.sdrToHdrTransfer) : undefined,
1126
+ work.finalFrameOnly ? "final-frame" : undefined,
1127
+ ].filter((part) => part !== undefined);
1128
+ const transform = transformParts.length > 0 ? transformParts.join("+") : undefined;
876
1129
  const lookup = lookupCacheEntry(cacheRootDir, {
877
1130
  videoPath: keyInput.videoPath,
878
1131
  mtimeMs: keyInput.mtimeMs,
879
1132
  size: keyInput.size,
880
1133
  mediaStart: keyInput.mediaStart,
881
- duration: keyDuration,
882
- fps: options.fps,
1134
+ duration: work.videoDuration,
1135
+ fps: fpsKey,
883
1136
  format: work.format,
884
1137
  transform,
885
1138
  });
@@ -897,7 +1150,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
897
1150
  const { work, cacheTarget } = miss;
898
1151
  if (!cacheTarget) {
899
1152
  const outputDir = join(options.outputDir, work.video.id);
900
- const attempted = await runVideoExtractionWithRetry(() => extractVideoFramesRange(work.videoPath, work.video.id, work.video.mediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config), {
1153
+ const attempted = await runVideoExtractionWithRetry(() => extractVideoFramesRange(work.videoPath, work.video.id, work.extractionMediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config), {
901
1154
  signal,
902
1155
  maxTransientRetries,
903
1156
  onRetry: () => {
@@ -910,7 +1163,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
910
1163
  const partialDir = partialCacheEntryDir(cacheTarget.entry);
911
1164
  rmSync(partialDir, { recursive: true, force: true });
912
1165
  mkdirSync(partialDir, { recursive: true });
913
- const attempted = await runVideoExtractionWithRetry(() => extractVideoFramesRange(work.videoPath, work.video.id, work.video.mediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config, partialDir), {
1166
+ const attempted = await runVideoExtractionWithRetry(() => extractVideoFramesRange(work.videoPath, work.video.id, work.extractionMediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config, partialDir), {
914
1167
  signal,
915
1168
  maxTransientRetries,
916
1169
  onRetry: () => {
@@ -939,10 +1192,10 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
939
1192
  const { miss } = member;
940
1193
  const { work, cacheTarget } = miss;
941
1194
  if (!cacheTarget) {
942
- return sliceSupersetMember(member, superset, join(options.outputDir, work.video.id), options.fps);
1195
+ return sliceSupersetMember(member, superset, join(options.outputDir, work.video.id), fps, configuredFps);
943
1196
  }
944
1197
  const partialDir = partialCacheEntryDir(cacheTarget.entry);
945
- const sliced = sliceSupersetMember(member, superset, partialDir, options.fps);
1198
+ const sliced = sliceSupersetMember(member, superset, partialDir, fps, configuredFps);
946
1199
  const published = publishCacheEntry(cacheTarget.entry, partialDir);
947
1200
  if (!published.published) {
948
1201
  breakdown.cachePublishFailures += 1;
@@ -1003,13 +1256,27 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1003
1256
  }
1004
1257
  try {
1005
1258
  const metadata = videoMetadata[index] ?? (await extractMediaMetadata(videoPath));
1006
- const videoDuration = resolveSegmentDuration(video.end - video.start, video.mediaStart, metadata);
1007
- if (video.end - video.start !== videoDuration) {
1008
- video.end = video.start + videoDuration;
1259
+ const initialWindow = resolveVideoExtractionWindow(video, metadata, options.timelineEnd);
1260
+ const window = await resolveFinalFrameExtractionWindow(videoPath, video, metadata, initialWindow, signal);
1261
+ const videoDuration = window.durationSeconds;
1262
+ if (videoDuration <= 0) {
1263
+ return { skipped: true };
1009
1264
  }
1265
+ if (!window.preserveTimelinePhase) {
1266
+ video.start = window.compositionStart;
1267
+ if (!window.preserveTimelineEnd) {
1268
+ video.end = window.compositionStart + videoDuration;
1269
+ }
1270
+ video.mediaStart = window.mediaStart;
1271
+ }
1272
+ const keyInput = cacheKeyInputs[index];
1273
+ const extractionMediaStart = window.extractionMediaStart ?? window.mediaStart;
1274
+ if (keyInput)
1275
+ keyInput.mediaStart = extractionMediaStart;
1010
1276
  const format = resolveFrameFormat(metadata, options.format);
1011
1277
  const sdrToHdrTransfer = sdrToHdrTransfers[index];
1012
- const dedupeKey = `${videoPath}\0${video.mediaStart}\0${videoDuration}\0${options.fps}\0${format}\0${sdrToHdrTransfer ?? ""}`;
1278
+ const finalFrameOnly = window.finalFrameOnly === true;
1279
+ const dedupeKey = `${videoPath}\0${extractionMediaStart}\0${videoDuration}\0${fpsKey}\0${format}\0${sdrToHdrTransfer ?? ""}\0${finalFrameOnly ? "final" : "range"}`;
1013
1280
  return {
1014
1281
  work: {
1015
1282
  video,
@@ -1017,6 +1284,8 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1017
1284
  index,
1018
1285
  metadata,
1019
1286
  videoDuration,
1287
+ extractionMediaStart,
1288
+ finalFrameOnly,
1020
1289
  format,
1021
1290
  sdrToHdrTransfer,
1022
1291
  dedupeKey,
@@ -1044,7 +1313,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1044
1313
  uniqueOutcomes.set(work.dedupeKey, lookup);
1045
1314
  }
1046
1315
  }
1047
- const supersetPlan = planSupersetGroups(cacheMisses, options.fps);
1316
+ const supersetPlan = planSupersetGroups(cacheMisses, fps);
1048
1317
  const directOutcomes = await Promise.all(supersetPlan.direct.map(async (miss) => [miss.work.dedupeKey, await executeDirectMiss(miss)]));
1049
1318
  for (const [key, outcome] of directOutcomes)
1050
1319
  uniqueOutcomes.set(key, outcome);
@@ -1053,12 +1322,21 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1053
1322
  for (const [key, outcome] of groupOutcomes)
1054
1323
  uniqueOutcomes.set(key, outcome);
1055
1324
  }
1056
- const results = preparedExtractions.map((prepared) => {
1057
- if ("error" in prepared)
1058
- return prepared;
1325
+ const results = [];
1326
+ for (const prepared of preparedExtractions) {
1327
+ if ("skipped" in prepared)
1328
+ continue;
1329
+ if ("error" in prepared) {
1330
+ results.push(prepared);
1331
+ continue;
1332
+ }
1059
1333
  const outcome = uniqueOutcomes.get(prepared.work.dedupeKey);
1060
- if (!outcome)
1061
- return { error: extractionError(prepared.work.video.id, "missing extraction result") };
1334
+ if (!outcome) {
1335
+ results.push({
1336
+ error: extractionError(prepared.work.video.id, "missing extraction result"),
1337
+ });
1338
+ continue;
1339
+ }
1062
1340
  if ("error" in outcome) {
1063
1341
  // A shared (deduped/superset) failure fans out to every element with the
1064
1342
  // same key; annotate followers with the leader's videoId so N copies of
@@ -1067,17 +1345,18 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1067
1345
  const message = isFollower
1068
1346
  ? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
1069
1347
  : outcome.error.error;
1070
- return {
1348
+ results.push({
1071
1349
  error: {
1072
1350
  videoId: prepared.work.video.id,
1073
1351
  kind: outcome.error.kind,
1074
1352
  retryable: outcome.error.retryable,
1075
1353
  error: message,
1076
1354
  },
1077
- };
1355
+ });
1356
+ continue;
1078
1357
  }
1079
- return { result: { ...outcome.result, videoId: prepared.work.video.id } };
1080
- });
1358
+ results.push({ result: { ...outcome.result, videoId: prepared.work.video.id } });
1359
+ }
1081
1360
  breakdown.extractMs = Date.now() - phase3Start;
1082
1361
  // Collect results and errors
1083
1362
  for (const item of results) {
@@ -1115,7 +1394,7 @@ function getFrameIndexAtTime(extracted, globalTime, videoStart, loop = false, me
1115
1394
  let localTime = globalTime - videoStart;
1116
1395
  if (localTime < 0)
1117
1396
  return null;
1118
- const loopDuration = Math.max(0, extracted.metadata.durationSeconds - mediaStart);
1397
+ const loopDuration = Math.max(0, resolvePlayableVideoDuration(extracted.metadata) - mediaStart);
1119
1398
  if (loop && loopDuration > 0 && localTime >= loopDuration) {
1120
1399
  localTime %= loopDuration;
1121
1400
  }