@hyperframes/engine 0.8.191 → 0.8.192
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 +2 -2
- package/dist/index.js +2 -2
- package/dist/services/audioMixer.js +85 -14
- package/dist/services/audioMixer.types.d.ts +1 -0
- package/dist/services/chunkEncoder.js +1 -1
- package/dist/services/extractionCache.js +1 -1
- package/dist/services/softwareEncoderThreads.d.ts +2 -1
- package/dist/services/softwareEncoderThreads.js +4 -2
- package/dist/services/streamingEncoder.js +1 -1
- package/dist/utils/ffprobe.d.ts +2 -0
- package/dist/utils/ffprobe.js +53 -38
- package/dist/utils/htmlTemplate.js +1 -1
- package/dist/utils/processRss.js +5 -2
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
|
|
|
46
46
|
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
|
|
47
47
|
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
|
|
48
48
|
export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
|
|
49
|
-
export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
|
|
49
|
+
export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
|
|
50
50
|
export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
|
|
51
51
|
export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
|
|
52
52
|
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
|
@@ -64,7 +64,7 @@ export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionB
|
|
|
64
64
|
export type { PageMotionBlurOff } from "./types.js";
|
|
65
65
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
|
66
66
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
67
|
-
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
|
|
67
|
+
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
|
|
68
68
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
69
69
|
export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
|
|
70
70
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
|
package/dist/index.js
CHANGED
|
@@ -50,7 +50,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
|
|
|
50
50
|
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
|
|
51
51
|
export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
|
|
52
52
|
// ── Media processing ───────────────────────────────────────────────────────────
|
|
53
|
-
export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
|
|
53
|
+
export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
|
|
54
54
|
export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
|
|
55
55
|
export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
|
|
56
56
|
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
|
@@ -68,7 +68,7 @@ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThrea
|
|
|
68
68
|
export { frameSequencePath } from "./services/extractedFrameIndex.js";
|
|
69
69
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
|
70
70
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
71
|
-
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
|
|
71
|
+
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
|
|
72
72
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
73
73
|
export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
|
|
74
74
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
|
|
@@ -133,6 +133,8 @@ function legacyFilterScriptOptionIsUnsupported(stderr) {
|
|
|
133
133
|
*/
|
|
134
134
|
// Caps the ffmpeg runs that prepare clips; all of a long mix's clips at once starved the host (200 clips, 200 ffmpeg).
|
|
135
135
|
const inPrepareSlot = createConcurrencyLimit(Math.max(2, Math.floor(cpus().length / 2)));
|
|
136
|
+
// A clip read from a URL spends its time waiting on the network, so it needs no CPU-sized limit.
|
|
137
|
+
const inRemotePrepareSlot = createConcurrencyLimit(16);
|
|
136
138
|
const MAX_VOLUME_SEGMENTS = 32;
|
|
137
139
|
/**
|
|
138
140
|
* Volume delta below which a keyframe is collinear enough to drop. Kept tight
|
|
@@ -428,11 +430,13 @@ export function parseAudioElements(html) {
|
|
|
428
430
|
const startCache = new Map();
|
|
429
431
|
const visiting = new Set();
|
|
430
432
|
const resolveStart = (el) => el.getAttribute("data-start") ? resolveReferencedStart(document, el, startCache, visiting) : 0;
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
433
|
+
// Same rule as core's compileTimingAttrs: data-end, else start + data-duration, else 0 (unknown).
|
|
434
|
+
const resolveEnd = (el) => {
|
|
435
|
+
const end = parseStrictFiniteTimingNumber(el.getAttribute("data-end"));
|
|
436
|
+
if (end != null)
|
|
437
|
+
return end;
|
|
438
|
+
const duration = parseStrictFiniteTimingNumber(el.getAttribute("data-duration"));
|
|
439
|
+
return duration != null && duration > 0 ? resolveStart(el) + duration : 0;
|
|
436
440
|
};
|
|
437
441
|
const isHidden = isSelfOrAncestorHidden;
|
|
438
442
|
// Resolved once per parse. A group element carrying `data-hidden` drops
|
|
@@ -454,9 +458,10 @@ export function parseAudioElements(html) {
|
|
|
454
458
|
id,
|
|
455
459
|
src,
|
|
456
460
|
start: resolveStart(el),
|
|
457
|
-
end:
|
|
461
|
+
end: resolveEnd(el),
|
|
458
462
|
mediaStart: readMediaStart(el),
|
|
459
463
|
playbackRate: readElementRateSpec(el),
|
|
464
|
+
loop: el.getAttribute("loop") !== null,
|
|
460
465
|
layer: layerAttr ? parseInt(layerAttr) : 0,
|
|
461
466
|
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
|
|
462
467
|
...(fades.fadeIn > 0 ? { fadeIn: fades.fadeIn } : {}),
|
|
@@ -506,14 +511,14 @@ const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
|
506
511
|
const SIDECAR_WARM_UP_SECONDS = 1;
|
|
507
512
|
/** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
|
|
508
513
|
* started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
|
|
509
|
-
async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl) {
|
|
514
|
+
async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl, options) {
|
|
510
515
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
511
516
|
const outputDir = dirname(outputPath);
|
|
512
517
|
if (!existsSync(outputDir))
|
|
513
518
|
mkdirSync(outputDir, { recursive: true });
|
|
514
519
|
const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
|
|
515
520
|
const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
|
|
516
|
-
// A failed local probe keeps FFmpeg's
|
|
521
|
+
// A failed non-looping local probe keeps FFmpeg's channel conversion and input seek; a URL probe
|
|
517
522
|
// is retried once, then fails: a blip must not change how the next, successful read is mixed.
|
|
518
523
|
const readsUrl = isHttpUrl(srcPath);
|
|
519
524
|
const metadata = readsUrl
|
|
@@ -522,7 +527,53 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
522
527
|
throw error;
|
|
523
528
|
return extractAudioMetadata(srcPath, { signal });
|
|
524
529
|
})
|
|
525
|
-
: await extractAudioMetadata(srcPath).catch(() =>
|
|
530
|
+
: await extractAudioMetadata(srcPath).catch((error) => {
|
|
531
|
+
if (options?.loop)
|
|
532
|
+
throw error;
|
|
533
|
+
return null;
|
|
534
|
+
});
|
|
535
|
+
const loopDuration = (metadata?.durationSeconds ?? 0) - mediaStart;
|
|
536
|
+
if (options?.loop && loopDuration > 0 && sourceDuration > loopDuration) {
|
|
537
|
+
const cycleDir = mkdtempSync(join(outputDir, ".audio-loop-"));
|
|
538
|
+
const cyclePath = join(cycleDir, "cycle.wav");
|
|
539
|
+
try {
|
|
540
|
+
const cycle = await extractAudioSegment(srcPath, cyclePath, stage, mediaStart, loopDuration, 1, signal, config, audioSidecarUrl, { padToDuration: true });
|
|
541
|
+
if (!cycle.success)
|
|
542
|
+
return { ...cycle, outputPath };
|
|
543
|
+
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null);
|
|
544
|
+
const result = await runFfmpeg([
|
|
545
|
+
"-stream_loop",
|
|
546
|
+
"-1",
|
|
547
|
+
"-t",
|
|
548
|
+
String(sourceDuration),
|
|
549
|
+
"-i",
|
|
550
|
+
cyclePath,
|
|
551
|
+
"-vn",
|
|
552
|
+
"-acodec",
|
|
553
|
+
"pcm_s16le",
|
|
554
|
+
"-ar",
|
|
555
|
+
"48000",
|
|
556
|
+
...outputArgs,
|
|
557
|
+
"-t",
|
|
558
|
+
String(duration),
|
|
559
|
+
"-y",
|
|
560
|
+
outputPath,
|
|
561
|
+
], { signal, timeout: ffmpegProcessTimeout });
|
|
562
|
+
const failure = result.success
|
|
563
|
+
? undefined
|
|
564
|
+
: ffmpegFailure(stage, result, ffmpegProcessTimeout);
|
|
565
|
+
return {
|
|
566
|
+
success: result.success,
|
|
567
|
+
outputPath,
|
|
568
|
+
durationMs: cycle.durationMs + result.durationMs,
|
|
569
|
+
error: failure?.detail,
|
|
570
|
+
failure,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
rmSync(cycleDir, { recursive: true, force: true });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
526
577
|
const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
|
|
527
578
|
const start = formatFilterNumber(mediaStart);
|
|
528
579
|
const timestampTrim = inputSeek
|
|
@@ -530,7 +581,12 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
530
581
|
: `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
|
|
531
582
|
const fromSidecar = audioSidecarUrl !== undefined && inputSeek && normalizedPlaybackRate === 1;
|
|
532
583
|
const warmUp = fromSidecar ? Math.min(SIDECAR_WARM_UP_SECONDS, mediaStart) : 0;
|
|
533
|
-
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration,
|
|
584
|
+
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, [
|
|
585
|
+
warmUp > 0 ? `atrim=start=${warmUp},asetpts=PTS-STARTPTS` : timestampTrim,
|
|
586
|
+
...(options?.padToDuration ? ["apad"] : []),
|
|
587
|
+
]
|
|
588
|
+
.filter(Boolean)
|
|
589
|
+
.join(",") || null);
|
|
534
590
|
const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
|
|
535
591
|
const args = [
|
|
536
592
|
...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
|
|
@@ -542,7 +598,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
542
598
|
readFrom,
|
|
543
599
|
];
|
|
544
600
|
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
|
|
545
|
-
if (normalizedPlaybackRate !== 1)
|
|
601
|
+
if (normalizedPlaybackRate !== 1 || options?.padToDuration)
|
|
546
602
|
args.push("-t", String(duration));
|
|
547
603
|
args.push("-y", outputPath);
|
|
548
604
|
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
|
@@ -575,6 +631,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
575
631
|
failure,
|
|
576
632
|
};
|
|
577
633
|
}
|
|
634
|
+
function isRemoteElement(element, baseDir, compiledDir, config) {
|
|
635
|
+
if (isHttpUrl(element.src))
|
|
636
|
+
return true;
|
|
637
|
+
if (!config?.urlByAbsentLocalPath?.size)
|
|
638
|
+
return false;
|
|
639
|
+
try {
|
|
640
|
+
return config.urlByAbsentLocalPath.has(resolveProjectRelativeSrc(element.src, baseDir, compiledDir));
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
578
646
|
async function generateSilence(outputPath, duration, signal, config) {
|
|
579
647
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
580
648
|
const outputDir = dirname(outputPath);
|
|
@@ -1053,7 +1121,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1053
1121
|
else
|
|
1054
1122
|
signal.addEventListener("abort", () => internalController.abort(), { once: true });
|
|
1055
1123
|
}
|
|
1056
|
-
await Promise.all(elements.map((element, index) =>
|
|
1124
|
+
await Promise.all(elements.map((element, index) => (isRemoteElement(element, baseDir, compiledDir, config)
|
|
1125
|
+
? inRemotePrepareSlot
|
|
1126
|
+
: inPrepareSlot)(async () => {
|
|
1057
1127
|
if (effectiveSignal.aborted) {
|
|
1058
1128
|
failures.push({
|
|
1059
1129
|
stage: "cancelled",
|
|
@@ -1115,6 +1185,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1115
1185
|
}
|
|
1116
1186
|
// Fallback: if no duration was specified, probe the actual file
|
|
1117
1187
|
if (element.end - element.start <= 0) {
|
|
1188
|
+
console.warn(`[audioMixer] ${element.id} has no data-duration or data-end; its length is unknown, so it plays to the end of its source`);
|
|
1118
1189
|
let metadata;
|
|
1119
1190
|
try {
|
|
1120
1191
|
metadata = await extractAudioMetadata(srcPath, readInPlace ? { signal: effectiveSignal } : undefined);
|
|
@@ -1131,7 +1202,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1131
1202
|
let audioSrcPath = srcPath;
|
|
1132
1203
|
if (element.type === "video") {
|
|
1133
1204
|
const extractedPath = join(workDir, `${element.id}-extracted.wav`);
|
|
1134
|
-
const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl);
|
|
1205
|
+
const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl, { loop: element.loop });
|
|
1135
1206
|
if (!extractResult.success) {
|
|
1136
1207
|
failures.push(extractResult.failure
|
|
1137
1208
|
? { ...extractResult.failure, elementId: element.id }
|
|
@@ -1149,7 +1220,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1149
1220
|
}
|
|
1150
1221
|
else {
|
|
1151
1222
|
const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
|
|
1152
|
-
const prepResult = await extractAudioSegment(srcPath, trimmedPath, "prepare", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
|
|
1223
|
+
const prepResult = await extractAudioSegment(srcPath, trimmedPath, "prepare", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, undefined, { loop: element.loop });
|
|
1153
1224
|
if (!prepResult.success) {
|
|
1154
1225
|
failures.push(prepResult.failure
|
|
1155
1226
|
? { ...prepResult.failure, elementId: element.id }
|
|
@@ -11,6 +11,7 @@ export interface AudioElement {
|
|
|
11
11
|
mediaStart: number;
|
|
12
12
|
/** Normalized source-time multiplier: a constant, or the clip's `rate` lane. */
|
|
13
13
|
playbackRate?: RateSpec;
|
|
14
|
+
loop?: boolean;
|
|
14
15
|
layer: number;
|
|
15
16
|
volume?: number;
|
|
16
17
|
volumeKeyframes?: AudioVolumeKeyframe[];
|
|
@@ -214,7 +214,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
|
|
|
214
214
|
else {
|
|
215
215
|
const encoderName = codec === "h264" ? "libx264" : "libx265";
|
|
216
216
|
args.push("-c:v", encoderName, "-preset", preset);
|
|
217
|
-
appendSoftwareEncoderThreadsArg(args);
|
|
217
|
+
appendSoftwareEncoderThreadsArg(args, encoderName);
|
|
218
218
|
if (bitrate)
|
|
219
219
|
args.push("-b:v", bitrate);
|
|
220
220
|
else
|
|
@@ -172,7 +172,7 @@ function isTargetExistsRenameError(err) {
|
|
|
172
172
|
* winner is always correct. Returns null when no winner is present.
|
|
173
173
|
*/
|
|
174
174
|
function adoptPublishedWinner(entry, partialDir) {
|
|
175
|
-
if (!existsSync(join(entry.dir, COMPLETE_SENTINEL)))
|
|
175
|
+
if (!existsSync(join(entry.dir, COMPLETE_SENTINEL)) || !hasFrameFiles(entry.dir))
|
|
176
176
|
return null;
|
|
177
177
|
removeDir(partialDir);
|
|
178
178
|
return { dir: entry.dir, published: true };
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
export declare const X265_MAX_THREADS = 16;
|
|
1
2
|
/** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
|
|
2
|
-
export declare function appendSoftwareEncoderThreadsArg(args: string[], parallelism?: number): void;
|
|
3
|
+
export declare function appendSoftwareEncoderThreadsArg(args: string[], encoder: "libx264" | "libx265", parallelism?: number): void;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { availableParallelism } from "node:os";
|
|
2
|
+
export const X265_MAX_THREADS = 16;
|
|
2
3
|
/** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
|
|
3
|
-
export function appendSoftwareEncoderThreadsArg(args, parallelism = availableParallelism()) {
|
|
4
|
-
|
|
4
|
+
export function appendSoftwareEncoderThreadsArg(args, encoder, parallelism = availableParallelism()) {
|
|
5
|
+
const cap = encoder === "libx265" ? X265_MAX_THREADS : Infinity;
|
|
6
|
+
args.push("-threads", String(Math.min(cap, Math.max(1, Math.floor(parallelism)))));
|
|
5
7
|
}
|
|
@@ -181,7 +181,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
|
|
|
181
181
|
else {
|
|
182
182
|
const encoderName = codec === "h264" ? "libx264" : "libx265";
|
|
183
183
|
args.push("-c:v", encoderName, "-preset", preset);
|
|
184
|
-
appendSoftwareEncoderThreadsArg(args);
|
|
184
|
+
appendSoftwareEncoderThreadsArg(args, encoderName);
|
|
185
185
|
if (bitrate)
|
|
186
186
|
args.push("-b:v", bitrate);
|
|
187
187
|
else
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -100,6 +100,8 @@ export interface MediaProbeProfile {
|
|
|
100
100
|
hasAudioStream: boolean;
|
|
101
101
|
visualKind: "none" | "still" | "moving";
|
|
102
102
|
}
|
|
103
|
+
/** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
|
|
104
|
+
export declare function declareContentAddressedUrl(url: string, sha256: string): void;
|
|
103
105
|
export declare function mediaFileIdentity(filePath: string): string | null;
|
|
104
106
|
export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
|
|
105
107
|
export declare function writeMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null, promise: Promise<T>): void;
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -140,6 +140,7 @@ const videoMetadataCache = new Map();
|
|
|
140
140
|
const finalVideoFrameTimestampCache = new Map();
|
|
141
141
|
const finalVideoFrameTimestampSignalCaches = new WeakMap();
|
|
142
142
|
const audioMetadataCache = new Map();
|
|
143
|
+
const audioMetadataSignalCaches = new WeakMap();
|
|
143
144
|
const mediaProbeOutputCache = new Map();
|
|
144
145
|
const mediaProbeOutputSignalCaches = new WeakMap();
|
|
145
146
|
const MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES = 128;
|
|
@@ -210,7 +211,17 @@ function isPngImageProbe(output) {
|
|
|
210
211
|
return (formatNames.includes("png_pipe") ||
|
|
211
212
|
output.streams.some((stream) => stream.codec_type === "video" && stream.codec_name?.toLowerCase() === "png"));
|
|
212
213
|
}
|
|
214
|
+
const contentAddressedUrls = new Map();
|
|
215
|
+
const withoutQuery = (url) => url.split(/[?#]/, 1)[0] ?? url;
|
|
216
|
+
/** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
|
|
217
|
+
export function declareContentAddressedUrl(url, sha256) {
|
|
218
|
+
contentAddressedUrls.set(withoutQuery(url), sha256);
|
|
219
|
+
}
|
|
213
220
|
export function mediaFileIdentity(filePath) {
|
|
221
|
+
if (/^https?:\/\//i.test(filePath)) {
|
|
222
|
+
const sha256 = contentAddressedUrls.get(withoutQuery(filePath));
|
|
223
|
+
return sha256 ? `sha256:${sha256}` : null;
|
|
224
|
+
}
|
|
214
225
|
try {
|
|
215
226
|
const stat = statSync(filePath, { bigint: true });
|
|
216
227
|
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
@@ -740,49 +751,53 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
|
|
|
740
751
|
*/
|
|
741
752
|
export const extractVideoMetadata = extractMediaMetadata;
|
|
742
753
|
export async function extractAudioMetadata(filePath, options) {
|
|
743
|
-
// A caller
|
|
744
|
-
//
|
|
745
|
-
// probes therefore bypass the process-promise cache.
|
|
754
|
+
// A probe bound to a caller's abort signal is shared only among callers of that same signal,
|
|
755
|
+
// and only when the file has an identity to key it by.
|
|
746
756
|
const identity = mediaFileIdentity(filePath);
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
757
|
+
const signal = options?.signal;
|
|
758
|
+
if (signal && identity === null)
|
|
759
|
+
return probeAudioMetadata(filePath, signal);
|
|
760
|
+
let cache = audioMetadataCache;
|
|
761
|
+
if (signal) {
|
|
762
|
+
cache = audioMetadataSignalCaches.get(signal) ?? new Map();
|
|
763
|
+
audioMetadataSignalCaches.set(signal, cache);
|
|
764
|
+
}
|
|
765
|
+
const cached = readMetadataCache(cache, filePath, identity);
|
|
750
766
|
if (cached)
|
|
751
767
|
return cached;
|
|
752
|
-
const probePromise = (
|
|
753
|
-
|
|
754
|
-
const audioStream = output.streams.find((s) => s.codec_type === "audio");
|
|
755
|
-
if (!audioStream)
|
|
756
|
-
throw new Error("[FFmpeg] No audio stream found");
|
|
757
|
-
let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
|
758
|
-
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
|
|
759
|
-
const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
|
|
760
|
-
const audioCodec = audioStream.codec_name || "unknown";
|
|
761
|
-
// Container summaries can undercount intact audio, so the decoded last frame wins.
|
|
762
|
-
// The probe returns null instead of throwing: a throw here drops the audio element.
|
|
763
|
-
if (sampleRate > 0) {
|
|
764
|
-
const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, options?.signal);
|
|
765
|
-
if (decoded !== null) {
|
|
766
|
-
durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
return {
|
|
770
|
-
durationSeconds,
|
|
771
|
-
streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
|
|
772
|
-
sampleRate,
|
|
773
|
-
channels: audioStream.channels || 2,
|
|
774
|
-
audioCodec,
|
|
775
|
-
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
|
|
776
|
-
latestStreamLeadSeconds: latestStreamLeadSeconds(output),
|
|
777
|
-
audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
|
|
778
|
-
audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
|
|
779
|
-
};
|
|
780
|
-
})();
|
|
781
|
-
if (options?.signal)
|
|
782
|
-
return probePromise;
|
|
783
|
-
writeMetadataCache(audioMetadataCache, filePath, identity, probePromise);
|
|
768
|
+
const probePromise = probeAudioMetadata(filePath, signal);
|
|
769
|
+
writeMetadataCache(cache, filePath, identity, probePromise);
|
|
784
770
|
return probePromise;
|
|
785
771
|
}
|
|
772
|
+
async function probeAudioMetadata(filePath, signal) {
|
|
773
|
+
const output = await probeMediaOutput(filePath, signal);
|
|
774
|
+
const audioStream = output.streams.find((s) => s.codec_type === "audio");
|
|
775
|
+
if (!audioStream)
|
|
776
|
+
throw new Error("[FFmpeg] No audio stream found");
|
|
777
|
+
let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
|
778
|
+
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
|
|
779
|
+
const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
|
|
780
|
+
const audioCodec = audioStream.codec_name || "unknown";
|
|
781
|
+
// Container summaries can undercount intact audio, so the decoded last frame wins.
|
|
782
|
+
// The probe returns null instead of throwing: a throw here drops the audio element.
|
|
783
|
+
if (sampleRate > 0) {
|
|
784
|
+
const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
|
|
785
|
+
if (decoded !== null) {
|
|
786
|
+
durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
durationSeconds,
|
|
791
|
+
streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
|
|
792
|
+
sampleRate,
|
|
793
|
+
channels: audioStream.channels || 2,
|
|
794
|
+
audioCodec,
|
|
795
|
+
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
|
|
796
|
+
latestStreamLeadSeconds: latestStreamLeadSeconds(output),
|
|
797
|
+
audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
|
|
798
|
+
audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
786
801
|
/** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
|
|
787
802
|
function audioVideoStreamStarts(output) {
|
|
788
803
|
return (output?.streams ?? [])
|
|
@@ -22,7 +22,7 @@ function getSingleMeaningfulChild(container) {
|
|
|
22
22
|
*/
|
|
23
23
|
export function unwrapTemplate(html) {
|
|
24
24
|
const lowered = html.toLowerCase();
|
|
25
|
-
if (!lowered.includes("<template") ||
|
|
25
|
+
if (!lowered.includes("<template") || !/<\/template[ \t\n\r\f]*>/.test(lowered)) {
|
|
26
26
|
return html;
|
|
27
27
|
}
|
|
28
28
|
const { body } = parseHTMLContent(html);
|
package/dist/utils/processRss.js
CHANGED
|
@@ -78,8 +78,11 @@ export async function sampleProcessRss(pids, exec = defaultExec(), platform = pr
|
|
|
78
78
|
if (platform === "win32") {
|
|
79
79
|
const results = [];
|
|
80
80
|
for (const pid of unique) {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
try {
|
|
82
|
+
const { stdout } = await exec("tasklist", ["/FO", "CSV", "/NH", "/FI", `PID eq ${pid}`]);
|
|
83
|
+
results.push(...parseTasklistCsv(pid, stdout));
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
83
86
|
}
|
|
84
87
|
return results;
|
|
85
88
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.192",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"linkedom": "^0.18.12",
|
|
51
51
|
"puppeteer": "^25.8.0",
|
|
52
52
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
53
|
+
"@hyperframes/core": "^0.8.192",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.192"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|