@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.
- 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/extractionCache.d.ts +6 -3
- package/dist/services/extractionCache.d.ts.map +1 -1
- package/dist/services/extractionCache.js +4 -1
- package/dist/services/extractionCache.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 +79 -1
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +331 -52
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/utils/ffprobe.d.ts +33 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +298 -18
- package/dist/utils/ffprobe.js.map +1 -1
- package/package.json +3 -3
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
262
|
-
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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=${
|
|
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",
|
|
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,
|
|
473
|
+
function resolveSegmentDuration(requested, mediaStart, sourceDuration) {
|
|
388
474
|
if (Number.isFinite(requested) && requested > 0)
|
|
389
475
|
return requested;
|
|
390
|
-
const sourceRemaining =
|
|
391
|
-
return sourceRemaining > 0 ? sourceRemaining :
|
|
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 [
|
|
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 =
|
|
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
|
|
693
|
-
//
|
|
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
|
-
|
|
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) ≥
|
|
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 {
|
|
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
|
|
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
|
|
873
|
-
? sdrToHdrTransformKey(work.sdrToHdrTransfer)
|
|
874
|
-
: undefined
|
|
875
|
-
|
|
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:
|
|
882
|
-
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.
|
|
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.
|
|
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),
|
|
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,
|
|
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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1397
|
+
const loopDuration = Math.max(0, resolvePlayableVideoDuration(extracted.metadata) - mediaStart);
|
|
1119
1398
|
if (loop && loopDuration > 0 && localTime >= loopDuration) {
|
|
1120
1399
|
localTime %= loopDuration;
|
|
1121
1400
|
}
|