@hyperframes/engine 0.7.88 → 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.
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/services/systemMemory.d.ts +5 -0
- package/dist/services/systemMemory.d.ts.map +1 -1
- package/dist/services/systemMemory.js +6 -2
- package/dist/services/systemMemory.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts +66 -0
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +238 -41
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/utils/ffprobe.d.ts +20 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +95 -5
- package/dist/utils/ffprobe.js.map +1 -1
- package/package.json +3 -3
|
@@ -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
|
-
//
|
|
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
|
-
|
|
262
|
-
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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,
|
|
401
|
+
function resolveSegmentDuration(requested, mediaStart, sourceDuration) {
|
|
388
402
|
if (Number.isFinite(requested) && requested > 0)
|
|
389
403
|
return requested;
|
|
390
|
-
const sourceRemaining =
|
|
391
|
-
return sourceRemaining > 0 ? sourceRemaining :
|
|
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 [
|
|
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
|
|
693
|
-
//
|
|
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
|
-
|
|
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) ≥
|
|
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 {
|
|
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
|
|
873
|
-
? sdrToHdrTransformKey(work.sdrToHdrTransfer)
|
|
874
|
-
: undefined
|
|
875
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
|
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 =
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1315
|
+
const loopDuration = Math.max(0, resolvePlayableVideoDuration(extracted.metadata) - mediaStart);
|
|
1119
1316
|
if (loop && loopDuration > 0 && localTime >= loopDuration) {
|
|
1120
1317
|
localTime %= loopDuration;
|
|
1121
1318
|
}
|