@hyperframes/engine 0.7.89 → 0.7.90

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.
@@ -10,7 +10,7 @@ import { isAbsolute, join, posix, resolve, sep } from "path";
10
10
  import { parseHTML } from "linkedom";
11
11
  import { decodeUrlPathVariants, MEDIA_DURATION_CLAMP_EPSILON_SECONDS } 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";
@@ -180,7 +180,10 @@ export function parseVideoElements(html) {
180
180
  // reference; the resolver handles both.
181
181
  const start = startAttr ? resolveReferencedStart(document, el, startCache, visiting) : 0;
182
182
  // Derive end from data-end → data-start+data-duration → Infinity (natural duration).
183
- // The caller (htmlCompiler) clamps Infinity to the composition's absoluteEnd.
183
+ // Static compilation cannot always clamp root media because GSAP may supply
184
+ // the root duration at runtime. The producer passes the resolved timeline
185
+ // end into frame extraction, which caps the source duration only after the
186
+ // natural duration is known without rewriting authored timing metadata.
184
187
  let end = 0;
185
188
  if (endAttr) {
186
189
  end = parseFloat(endAttr);
@@ -258,11 +261,12 @@ outputDirOverride) {
258
261
  catch (error) {
259
262
  throw classifyVideoExtractionError(error);
260
263
  }
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`);
264
+ const playableDuration = resolvePlayableVideoDuration(metadata);
265
+ if (!(playableDuration > 0)) {
266
+ throw new VideoSourceExtractionError("invalid_media", false, "Video source has no positive duration", `Playable video stream duration is ${playableDuration}s`);
263
267
  }
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`);
268
+ if (startTime >= playableDuration) {
269
+ 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
270
  }
267
271
  const format = resolveFrameFormat(metadata, options.format);
268
272
  const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
@@ -289,13 +293,22 @@ outputDirOverride) {
289
293
  if (codecMayHaveAlpha(metadata.videoCodec)) {
290
294
  args.push("-c:v", decoderForCodec(metadata.videoCodec));
291
295
  }
292
- args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
296
+ if (options.finalFrameOnly) {
297
+ // Output-side seek decodes from the start before selecting the final
298
+ // sample. This is intentionally reserved for the one-frame path: input
299
+ // seeking is faster, but valid unindexed transports (notably MPEG-TS with
300
+ // a negative timestamp base) can seek to EOF and emit zero frames.
301
+ args.push("-i", videoPath, "-ss", String(startTime), "-frames:v", "1");
302
+ }
303
+ else {
304
+ args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
305
+ }
293
306
  const vfFilters = [];
294
307
  if (isHdr && isMacOS) {
295
308
  // VideoToolbox tone-maps during decode; force output to bt709 SDR format
296
309
  vfFilters.push("format=nv12");
297
310
  }
298
- if (!metadata.isVFR) {
311
+ if (!options.finalFrameOnly && !metadata.isVFR) {
299
312
  vfFilters.push(`fps=${fps}`);
300
313
  }
301
314
  if (options.sdrToHdrTransfer) {
@@ -310,8 +323,9 @@ outputDirOverride) {
310
323
  }
311
324
  if (vfFilters.length > 0)
312
325
  args.push("-vf", vfFilters.join(","));
313
- if (metadata.isVFR)
326
+ if (!options.finalFrameOnly && metadata.isVFR) {
314
327
  args.push("-fps_mode", "cfr", "-r", String(fps));
328
+ }
315
329
  args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0");
316
330
  // Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
317
331
  if (format === "png")
@@ -384,11 +398,149 @@ export function classifyFfmpegSpawnError(error, stderr = "") {
384
398
  * natural duration when the caller hasn't specified bounds (end=Infinity) or
385
399
  * the bounds are nonsensical (end<=start).
386
400
  */
387
- function resolveSegmentDuration(requested, mediaStart, metadata) {
401
+ function resolveSegmentDuration(requested, mediaStart, sourceDuration) {
388
402
  if (Number.isFinite(requested) && requested > 0)
389
403
  return requested;
390
- const sourceRemaining = metadata.durationSeconds - mediaStart;
391
- return sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
404
+ const sourceRemaining = sourceDuration - mediaStart;
405
+ return sourceRemaining > 0 ? sourceRemaining : sourceDuration;
406
+ }
407
+ /**
408
+ * Return the range that can actually produce video frames.
409
+ *
410
+ * Container duration may include a longer audio stream or mux padding. Using
411
+ * it for video extraction planning can reserve raw-frame scratch for seconds
412
+ * where no video frames exist. `extractMediaMetadata` already falls back to
413
+ * the container duration when ffprobe omits the stream duration; keep the
414
+ * explicit fallback here for callers supplying older/manual metadata.
415
+ */
416
+ export function resolvePlayableVideoDuration(metadata) {
417
+ return Number.isFinite(metadata.videoStreamDurationSeconds) &&
418
+ metadata.videoStreamDurationSeconds > 0
419
+ ? metadata.videoStreamDurationSeconds
420
+ : metadata.durationSeconds;
421
+ }
422
+ // Logical duration assigned to a one-frame held-tail representation. This is
423
+ // deliberately below any supported output frame interval: coverage expects
424
+ // one frame, while FFmpeg seeks to the separately probed real frame timestamp.
425
+ const FINAL_FRAME_LOGICAL_DURATION_SECONDS = 1e-6;
426
+ /**
427
+ * Intersect an authored slot with the render timeline, then select the
428
+ * smallest playable source range that preserves timeline lookup semantics.
429
+ *
430
+ * A finite authored slot can outlive the source. In that case FFmpeg should
431
+ * still extract at most one source range: lookup either wraps that range for
432
+ * loops or holds its final frame for non-looping video. Keeping the authored
433
+ * timeline origin separate from the extracted range is what makes both
434
+ * behaviours survive the source-duration cap.
435
+ */
436
+ export function resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd, sourceDuration) {
437
+ if (timelineEnd === undefined) {
438
+ return {
439
+ compositionStart: video.start,
440
+ mediaStart: video.mediaStart,
441
+ durationSeconds: resolvedDuration,
442
+ };
443
+ }
444
+ if (!Number.isFinite(timelineEnd)) {
445
+ throw new Error(`Video extraction timelineEnd must be finite; got ${String(timelineEnd)}`);
446
+ }
447
+ const compositionStart = Math.max(0, video.start);
448
+ const trimmedPreroll = compositionStart - video.start;
449
+ const timelineDuration = Math.max(0, timelineEnd - compositionStart);
450
+ // Infinity means "natural source duration", not an authored infinite slot.
451
+ // Explicit finite slots may outlive the source (loop or held tail), while an
452
+ // omitted duration remains source-bounded exactly like the browser runtime.
453
+ const resolvedVisibleDuration = resolvedDuration - trimmedPreroll;
454
+ const visibleDuration = Math.max(0, Math.min(resolvedVisibleDuration, timelineDuration));
455
+ let mediaStart = video.mediaStart + trimmedPreroll;
456
+ if (visibleDuration > 0 && sourceDuration !== undefined) {
457
+ const sourceRemaining = Math.max(0, sourceDuration - video.mediaStart);
458
+ if (sourceRemaining > 0 && video.loop && Number.isFinite(video.end)) {
459
+ const phaseOffset = trimmedPreroll % sourceRemaining;
460
+ const phaseRemaining = sourceRemaining - phaseOffset;
461
+ // The element visibility contract includes its end boundary. Preserve a
462
+ // complete cycle on equality as well, otherwise a rebased suffix would
463
+ // wrap to its own first frame instead of the source cycle's first frame.
464
+ if (visibleDuration >= phaseRemaining) {
465
+ return {
466
+ compositionStart: video.start,
467
+ mediaStart: video.mediaStart,
468
+ durationSeconds: sourceRemaining,
469
+ preserveTimelinePhase: true,
470
+ };
471
+ }
472
+ mediaStart = video.mediaStart + phaseOffset;
473
+ }
474
+ else if (sourceRemaining > 0) {
475
+ const sourceVisibleAfterPreroll = Math.max(0, sourceRemaining - trimmedPreroll);
476
+ if (visibleDuration <= sourceVisibleAfterPreroll) {
477
+ return {
478
+ compositionStart,
479
+ mediaStart,
480
+ durationSeconds: visibleDuration,
481
+ };
482
+ }
483
+ // The visible interval enters (or is entirely inside) the held tail.
484
+ // Extract the visible source suffix. If preroll is already at/past the
485
+ // final decoded timestamp, the async resolver below replaces this tiny
486
+ // provisional suffix with one exact final frame.
487
+ const extractionDuration = Math.min(sourceRemaining, Math.max(sourceVisibleAfterPreroll, FINAL_FRAME_LOGICAL_DURATION_SECONDS));
488
+ const extractionOffset = sourceRemaining - extractionDuration;
489
+ return {
490
+ compositionStart: video.start + extractionOffset,
491
+ mediaStart: video.mediaStart + extractionOffset,
492
+ durationSeconds: extractionDuration,
493
+ preserveTimelineEnd: true,
494
+ ensureFinalFrame: true,
495
+ };
496
+ }
497
+ }
498
+ return {
499
+ compositionStart,
500
+ mediaStart,
501
+ durationSeconds: visibleDuration,
502
+ };
503
+ }
504
+ /**
505
+ * Replace a held-tail suffix that starts at/after the final decoded timestamp
506
+ * with one exact frame. This keeps raw HDR scratch O(one frame) without
507
+ * assuming a one-second seek window contains a CFR/VFR timestamp.
508
+ */
509
+ export async function resolveFinalFrameExtractionWindow(videoPath, video, metadata, window, signal) {
510
+ if (!window.ensureFinalFrame)
511
+ return window;
512
+ const playableDuration = resolvePlayableVideoDuration(metadata);
513
+ const finalFrameTimestamp = await extractFinalVideoFrameTimestamp(videoPath, {
514
+ videoStreamDurationSeconds: playableDuration,
515
+ videoStreamStartSeconds: metadata.videoStreamStartSeconds,
516
+ }, signal);
517
+ if (window.mediaStart < finalFrameTimestamp - 1e-9)
518
+ return window;
519
+ const sourceRemaining = playableDuration - video.mediaStart;
520
+ const logicalDuration = Math.min(sourceRemaining, FINAL_FRAME_LOGICAL_DURATION_SECONDS);
521
+ return {
522
+ compositionStart: Math.max(0, video.start),
523
+ mediaStart: playableDuration - logicalDuration,
524
+ extractionMediaStart: finalFrameTimestamp,
525
+ durationSeconds: logicalDuration,
526
+ preserveTimelineEnd: true,
527
+ finalFrameOnly: true,
528
+ };
529
+ }
530
+ /** Resolve source duration first, then intersect it with the render timeline. */
531
+ export function resolveVideoExtractionWindow(video, metadata, timelineEnd) {
532
+ const playableDuration = resolvePlayableVideoDuration(metadata);
533
+ if (!(playableDuration > 0)) {
534
+ throw new VideoSourceExtractionError("invalid_media", false, "Video source has no positive duration", `Playable video stream duration is ${playableDuration}s`);
535
+ }
536
+ if (video.mediaStart >= playableDuration) {
537
+ 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`);
538
+ }
539
+ const resolvedDuration = resolveSegmentDuration(video.end - video.start, video.mediaStart, playableDuration);
540
+ return resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd, playableDuration);
541
+ }
542
+ export function resolveVideoExtractionDuration(video, metadata, timelineEnd) {
543
+ return resolveVideoExtractionWindow(video, metadata, timelineEnd).durationSeconds;
392
544
  }
393
545
  /**
394
546
  * Codecs whose bitstream is allowed to carry an alpha channel. Default the
@@ -452,7 +604,13 @@ function linkOrCopyFrame(src, dest) {
452
604
  }
453
605
  }
454
606
  function supersetGroupingKey(work, fps) {
455
- return [work.videoPath, String(fps), work.format, work.sdrToHdrTransfer ?? ""].join("\0");
607
+ return [
608
+ work.videoPath,
609
+ String(fps),
610
+ work.format,
611
+ work.sdrToHdrTransfer ?? "",
612
+ work.finalFrameOnly ? "final" : "range",
613
+ ].join("\0");
456
614
  }
457
615
  function isIntegralFrameOffset(offsetSeconds, fps) {
458
616
  const frames = offsetSeconds * fps;
@@ -467,6 +625,8 @@ function windowsOverlapOrTouch(misses, baseStart) {
467
625
  function buildSupersetGroup(groupId, misses, fps) {
468
626
  if (misses.length < 2)
469
627
  return null;
628
+ if (misses.some(({ work }) => work.finalFrameOnly))
629
+ return null;
470
630
  const baseStart = Math.min(...misses.map(({ work }) => work.video.mediaStart));
471
631
  if (!misses.every(({ work }) => isIntegralFrameOffset(work.video.mediaStart - baseStart, fps))) {
472
632
  return null;
@@ -612,6 +772,9 @@ export function resolveProjectRelativeSrc(src, baseDir, compiledDir) {
612
772
  return candidates.find(existsSync) ?? join(baseDir, cleanSrc);
613
773
  }
614
774
  export async function extractAllVideoFrames(videos, baseDir, options, signal, config, compiledDir) {
775
+ if (options.timelineEnd !== undefined && !Number.isFinite(options.timelineEnd)) {
776
+ throw new Error(`Video extraction timelineEnd must be finite; got ${String(options.timelineEnd)}`);
777
+ }
615
778
  const startTime = Date.now();
616
779
  const extracted = [];
617
780
  const errors = [];
@@ -645,6 +808,8 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
645
808
  for (const video of videos) {
646
809
  if (signal?.aborted)
647
810
  break;
811
+ if (options.timelineEnd !== undefined && video.start >= options.timelineEnd)
812
+ continue;
648
813
  try {
649
814
  let videoPath = video.src;
650
815
  if (!isHttpUrl(videoPath)) {
@@ -689,10 +854,11 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
689
854
  }
690
855
  breakdown.resolveMs = Date.now() - phase1Start;
691
856
  // 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
857
+ // user-visible source path rather than the
858
+ // workDir-local normalized file produced by the
694
859
  // HDR preflight. Without this, every render would write a new
695
860
  // normalized file with a fresh mtime → fresh cache key → perpetual misses.
861
+ // Phase 3 updates mediaStart after trimming any invisible negative preroll.
696
862
  const cacheKeyInputs = resolvedVideos.map(({ video, videoPath }) => {
697
863
  const stat = readKeyStat(videoPath);
698
864
  // Missing files return null — skip the cache path for that entry. The
@@ -706,8 +872,6 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
706
872
  mtimeMs: stat.mtimeMs,
707
873
  size: stat.size,
708
874
  mediaStart: video.mediaStart,
709
- start: video.start,
710
- end: video.end,
711
875
  };
712
876
  });
713
877
  // Phase 2: Probe color spaces and normalize if mixed HDR/SDR
@@ -783,12 +947,13 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
783
947
  // Guard against mediaStart past EOF — FFmpeg's `-ss` silently produces
784
948
  // a 0-byte file when seeking beyond the source duration, and the
785
949
  // downstream extractor then points at a broken input.
786
- if (entry.video.mediaStart >= metadata.durationSeconds) {
950
+ const playableDuration = resolvePlayableVideoDuration(metadata);
951
+ if (entry.video.mediaStart >= playableDuration) {
787
952
  errors.push({
788
953
  videoId: entry.video.id,
789
954
  kind: "media_start_out_of_range",
790
955
  retryable: false,
791
- error: `SDR→HDR conversion skipped: mediaStart (${entry.video.mediaStart}s) ≥ source duration (${metadata.durationSeconds}s)`,
956
+ error: `SDR→HDR conversion skipped: mediaStart (${entry.video.mediaStart}s) ≥ playable video duration (${playableDuration}s)`,
792
957
  });
793
958
  hdrSkippedIndices.add(i);
794
959
  continue;
@@ -851,7 +1016,12 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
851
1016
  };
852
1017
  }
853
1018
  function scopedExtractionOptions(work) {
854
- return { ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer };
1019
+ return {
1020
+ ...options,
1021
+ format: work.format,
1022
+ sdrToHdrTransfer: work.sdrToHdrTransfer,
1023
+ finalFrameOnly: work.finalFrameOnly,
1024
+ };
855
1025
  }
856
1026
  function rehydratePublishedCache(work, target) {
857
1027
  const rehydrated = rehydrateCacheEntry(target.entry, {
@@ -869,16 +1039,17 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
869
1039
  const keyInput = cacheKeyInputs[work.index];
870
1040
  if (!keyInput)
871
1041
  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);
1042
+ const transformParts = [
1043
+ work.sdrToHdrTransfer ? sdrToHdrTransformKey(work.sdrToHdrTransfer) : undefined,
1044
+ work.finalFrameOnly ? "final-frame" : undefined,
1045
+ ].filter((part) => part !== undefined);
1046
+ const transform = transformParts.length > 0 ? transformParts.join("+") : undefined;
876
1047
  const lookup = lookupCacheEntry(cacheRootDir, {
877
1048
  videoPath: keyInput.videoPath,
878
1049
  mtimeMs: keyInput.mtimeMs,
879
1050
  size: keyInput.size,
880
1051
  mediaStart: keyInput.mediaStart,
881
- duration: keyDuration,
1052
+ duration: work.videoDuration,
882
1053
  fps: options.fps,
883
1054
  format: work.format,
884
1055
  transform,
@@ -897,7 +1068,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
897
1068
  const { work, cacheTarget } = miss;
898
1069
  if (!cacheTarget) {
899
1070
  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), {
1071
+ const attempted = await runVideoExtractionWithRetry(() => extractVideoFramesRange(work.videoPath, work.video.id, work.extractionMediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config), {
901
1072
  signal,
902
1073
  maxTransientRetries,
903
1074
  onRetry: () => {
@@ -910,7 +1081,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
910
1081
  const partialDir = partialCacheEntryDir(cacheTarget.entry);
911
1082
  rmSync(partialDir, { recursive: true, force: true });
912
1083
  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), {
1084
+ const attempted = await runVideoExtractionWithRetry(() => extractVideoFramesRange(work.videoPath, work.video.id, work.extractionMediaStart, work.videoDuration, scopedExtractionOptions(work), signal, config, partialDir), {
914
1085
  signal,
915
1086
  maxTransientRetries,
916
1087
  onRetry: () => {
@@ -1003,13 +1174,27 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1003
1174
  }
1004
1175
  try {
1005
1176
  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;
1177
+ const initialWindow = resolveVideoExtractionWindow(video, metadata, options.timelineEnd);
1178
+ const window = await resolveFinalFrameExtractionWindow(videoPath, video, metadata, initialWindow, signal);
1179
+ const videoDuration = window.durationSeconds;
1180
+ if (videoDuration <= 0) {
1181
+ return { skipped: true };
1182
+ }
1183
+ if (!window.preserveTimelinePhase) {
1184
+ video.start = window.compositionStart;
1185
+ if (!window.preserveTimelineEnd) {
1186
+ video.end = window.compositionStart + videoDuration;
1187
+ }
1188
+ video.mediaStart = window.mediaStart;
1009
1189
  }
1190
+ const keyInput = cacheKeyInputs[index];
1191
+ const extractionMediaStart = window.extractionMediaStart ?? window.mediaStart;
1192
+ if (keyInput)
1193
+ keyInput.mediaStart = extractionMediaStart;
1010
1194
  const format = resolveFrameFormat(metadata, options.format);
1011
1195
  const sdrToHdrTransfer = sdrToHdrTransfers[index];
1012
- const dedupeKey = `${videoPath}\0${video.mediaStart}\0${videoDuration}\0${options.fps}\0${format}\0${sdrToHdrTransfer ?? ""}`;
1196
+ const finalFrameOnly = window.finalFrameOnly === true;
1197
+ const dedupeKey = `${videoPath}\0${extractionMediaStart}\0${videoDuration}\0${options.fps}\0${format}\0${sdrToHdrTransfer ?? ""}\0${finalFrameOnly ? "final" : "range"}`;
1013
1198
  return {
1014
1199
  work: {
1015
1200
  video,
@@ -1017,6 +1202,8 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1017
1202
  index,
1018
1203
  metadata,
1019
1204
  videoDuration,
1205
+ extractionMediaStart,
1206
+ finalFrameOnly,
1020
1207
  format,
1021
1208
  sdrToHdrTransfer,
1022
1209
  dedupeKey,
@@ -1053,12 +1240,21 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1053
1240
  for (const [key, outcome] of groupOutcomes)
1054
1241
  uniqueOutcomes.set(key, outcome);
1055
1242
  }
1056
- const results = preparedExtractions.map((prepared) => {
1057
- if ("error" in prepared)
1058
- return prepared;
1243
+ const results = [];
1244
+ for (const prepared of preparedExtractions) {
1245
+ if ("skipped" in prepared)
1246
+ continue;
1247
+ if ("error" in prepared) {
1248
+ results.push(prepared);
1249
+ continue;
1250
+ }
1059
1251
  const outcome = uniqueOutcomes.get(prepared.work.dedupeKey);
1060
- if (!outcome)
1061
- return { error: extractionError(prepared.work.video.id, "missing extraction result") };
1252
+ if (!outcome) {
1253
+ results.push({
1254
+ error: extractionError(prepared.work.video.id, "missing extraction result"),
1255
+ });
1256
+ continue;
1257
+ }
1062
1258
  if ("error" in outcome) {
1063
1259
  // A shared (deduped/superset) failure fans out to every element with the
1064
1260
  // same key; annotate followers with the leader's videoId so N copies of
@@ -1067,17 +1263,18 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1067
1263
  const message = isFollower
1068
1264
  ? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
1069
1265
  : outcome.error.error;
1070
- return {
1266
+ results.push({
1071
1267
  error: {
1072
1268
  videoId: prepared.work.video.id,
1073
1269
  kind: outcome.error.kind,
1074
1270
  retryable: outcome.error.retryable,
1075
1271
  error: message,
1076
1272
  },
1077
- };
1273
+ });
1274
+ continue;
1078
1275
  }
1079
- return { result: { ...outcome.result, videoId: prepared.work.video.id } };
1080
- });
1276
+ results.push({ result: { ...outcome.result, videoId: prepared.work.video.id } });
1277
+ }
1081
1278
  breakdown.extractMs = Date.now() - phase3Start;
1082
1279
  // Collect results and errors
1083
1280
  for (const item of results) {
@@ -1115,7 +1312,7 @@ function getFrameIndexAtTime(extracted, globalTime, videoStart, loop = false, me
1115
1312
  let localTime = globalTime - videoStart;
1116
1313
  if (localTime < 0)
1117
1314
  return null;
1118
- const loopDuration = Math.max(0, extracted.metadata.durationSeconds - mediaStart);
1315
+ const loopDuration = Math.max(0, resolvePlayableVideoDuration(extracted.metadata) - mediaStart);
1119
1316
  if (loop && loopDuration > 0 && localTime >= loopDuration) {
1120
1317
  localTime %= loopDuration;
1121
1318
  }