@hyperframes/engine 0.8.190 → 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.d.ts +1 -0
- package/dist/services/audioMixer.js +96 -17
- package/dist/services/audioMixer.types.d.ts +1 -0
- package/dist/services/chunkEncoder.js +1 -1
- package/dist/services/drawElementService.js +1 -0
- package/dist/services/extractionCache.js +1 -1
- package/dist/services/frameCapture.d.ts +18 -2
- package/dist/services/frameCapture.js +129 -23
- package/dist/services/softwareEncoderThreads.d.ts +2 -1
- package/dist/services/softwareEncoderThreads.js +4 -2
- package/dist/services/streamingEncoder.js +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/utils/ffprobe.d.ts +4 -0
- package/dist/utils/ffprobe.js +54 -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";
|
|
@@ -37,5 +37,6 @@ export declare function processCompositionAudio(elements: AudioElement[], baseDi
|
|
|
37
37
|
/** Serialised master bus chain, from `parseMasterFxChain`. */
|
|
38
38
|
masterFxChain?: string;
|
|
39
39
|
urlByAbsentLocalPath?: ReadonlyMap<string, string>;
|
|
40
|
+
audioSidecarUrlByLocalPath?: ReadonlyMap<string, string>;
|
|
40
41
|
keepEncoderInputAt?: string;
|
|
41
42
|
}, compiledDir?: string): Promise<MixResult>;
|
|
@@ -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 } : {}),
|
|
@@ -503,16 +508,17 @@ export function parseAudioElements(html) {
|
|
|
503
508
|
return elements;
|
|
504
509
|
}
|
|
505
510
|
const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
511
|
+
const SIDECAR_WARM_UP_SECONDS = 1;
|
|
506
512
|
/** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
|
|
507
513
|
* started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
|
|
508
|
-
async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config) {
|
|
514
|
+
async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl, options) {
|
|
509
515
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
510
516
|
const outputDir = dirname(outputPath);
|
|
511
517
|
if (!existsSync(outputDir))
|
|
512
518
|
mkdirSync(outputDir, { recursive: true });
|
|
513
519
|
const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
|
|
514
520
|
const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
|
|
515
|
-
// 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
|
|
516
522
|
// is retried once, then fails: a blip must not change how the next, successful read is mixed.
|
|
517
523
|
const readsUrl = isHttpUrl(srcPath);
|
|
518
524
|
const metadata = readsUrl
|
|
@@ -521,22 +527,78 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
521
527
|
throw error;
|
|
522
528
|
return extractAudioMetadata(srcPath, { signal });
|
|
523
529
|
})
|
|
524
|
-
: 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
|
+
}
|
|
525
577
|
const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
|
|
526
578
|
const start = formatFilterNumber(mediaStart);
|
|
527
579
|
const timestampTrim = inputSeek
|
|
528
580
|
? null
|
|
529
581
|
: `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
|
|
530
|
-
const
|
|
582
|
+
const fromSidecar = audioSidecarUrl !== undefined && inputSeek && normalizedPlaybackRate === 1;
|
|
583
|
+
const warmUp = fromSidecar ? Math.min(SIDECAR_WARM_UP_SECONDS, mediaStart) : 0;
|
|
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);
|
|
590
|
+
const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
|
|
531
591
|
const args = [
|
|
532
592
|
...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
|
|
533
|
-
...urlInputArgs(
|
|
534
|
-
...(inputSeek
|
|
593
|
+
...urlInputArgs(readFrom),
|
|
594
|
+
...(inputSeek
|
|
595
|
+
? ["-ss", String(mediaStart - warmUp), "-t", String(sourceDuration + warmUp)]
|
|
596
|
+
: []),
|
|
535
597
|
"-i",
|
|
536
|
-
|
|
598
|
+
readFrom,
|
|
537
599
|
];
|
|
538
600
|
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
|
|
539
|
-
if (normalizedPlaybackRate !== 1)
|
|
601
|
+
if (normalizedPlaybackRate !== 1 || options?.padToDuration)
|
|
540
602
|
args.push("-t", String(duration));
|
|
541
603
|
args.push("-y", outputPath);
|
|
542
604
|
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
|
@@ -569,6 +631,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
569
631
|
failure,
|
|
570
632
|
};
|
|
571
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
|
+
}
|
|
572
646
|
async function generateSilence(outputPath, duration, signal, config) {
|
|
573
647
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
574
648
|
const outputDir = dirname(outputPath);
|
|
@@ -1047,7 +1121,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1047
1121
|
else
|
|
1048
1122
|
signal.addEventListener("abort", () => internalController.abort(), { once: true });
|
|
1049
1123
|
}
|
|
1050
|
-
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 () => {
|
|
1051
1127
|
if (effectiveSignal.aborted) {
|
|
1052
1128
|
failures.push({
|
|
1053
1129
|
stage: "cancelled",
|
|
@@ -1062,11 +1138,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1062
1138
|
try {
|
|
1063
1139
|
let srcPath = element.src;
|
|
1064
1140
|
let readInPlace = false;
|
|
1141
|
+
let audioSidecarUrl;
|
|
1065
1142
|
if (!isHttpUrl(srcPath)) {
|
|
1066
1143
|
// Same browser-URL path semantics as videos.
|
|
1067
1144
|
srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
|
|
1068
1145
|
const remoteUrl = config?.urlByAbsentLocalPath?.get(srcPath);
|
|
1069
1146
|
if (remoteUrl) {
|
|
1147
|
+
audioSidecarUrl = config?.audioSidecarUrlByLocalPath?.get(srcPath);
|
|
1070
1148
|
srcPath = remoteUrl;
|
|
1071
1149
|
readInPlace = true;
|
|
1072
1150
|
}
|
|
@@ -1107,6 +1185,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1107
1185
|
}
|
|
1108
1186
|
// Fallback: if no duration was specified, probe the actual file
|
|
1109
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`);
|
|
1110
1189
|
let metadata;
|
|
1111
1190
|
try {
|
|
1112
1191
|
metadata = await extractAudioMetadata(srcPath, readInPlace ? { signal: effectiveSignal } : undefined);
|
|
@@ -1123,7 +1202,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1123
1202
|
let audioSrcPath = srcPath;
|
|
1124
1203
|
if (element.type === "video") {
|
|
1125
1204
|
const extractedPath = join(workDir, `${element.id}-extracted.wav`);
|
|
1126
|
-
const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
|
|
1205
|
+
const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl, { loop: element.loop });
|
|
1127
1206
|
if (!extractResult.success) {
|
|
1128
1207
|
failures.push(extractResult.failure
|
|
1129
1208
|
? { ...extractResult.failure, elementId: element.id }
|
|
@@ -1141,7 +1220,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1141
1220
|
}
|
|
1142
1221
|
else {
|
|
1143
1222
|
const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
|
|
1144
|
-
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 });
|
|
1145
1224
|
if (!prepResult.success) {
|
|
1146
1225
|
failures.push(prepResult.failure
|
|
1147
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
|
|
@@ -934,6 +934,7 @@ export async function produceDrawElementFrameBatch(page, times, width, height, q
|
|
|
934
934
|
try {
|
|
935
935
|
if (aw.__hf && typeof aw.__hf.seek === "function")
|
|
936
936
|
aw.__hf.seek(t);
|
|
937
|
+
await aw.__hfWaitForSeekCompletion?.();
|
|
937
938
|
await aw.__hfDecodeDynamicCssBackgroundImages?.();
|
|
938
939
|
aw.__hf3d?.update();
|
|
939
940
|
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
|
|
@@ -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 };
|
|
@@ -98,6 +98,7 @@ export interface CaptureSession {
|
|
|
98
98
|
* our console output.
|
|
99
99
|
*/
|
|
100
100
|
pendingTimelineIds?: string[];
|
|
101
|
+
isolatedCompositions?: IsolatedComposition[];
|
|
101
102
|
/** The render's shared memo when the caller passed one, else this session's own. */
|
|
102
103
|
subTimelineWaitMemo: SubTimelineWaitMemo;
|
|
103
104
|
/** Structured readiness warnings surfaced to the producer's render policy. */
|
|
@@ -164,6 +165,12 @@ export interface CaptureSession {
|
|
|
164
165
|
* drawElement intermittently returns an anomalously small (blank) frame with no
|
|
165
166
|
* throw; a frame far below the running median is re-captured via screenshot. */
|
|
166
167
|
deFrameSizes?: number[];
|
|
168
|
+
/** Absolute floor of the serial drawElement size guard. Starts at 20000 bytes and drops to
|
|
169
|
+
* half of a small frame that redraws byte-identically. */
|
|
170
|
+
deAbsFloor?: number;
|
|
171
|
+
/** The last small drawElement frame that redrew byte-identically; an equal frame is kept
|
|
172
|
+
* without a redraw. */
|
|
173
|
+
deAcceptedSmall?: Buffer;
|
|
167
174
|
/** Last non-deduped encode result, reused for a static frame on the drawElement
|
|
168
175
|
* worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set
|
|
169
176
|
* when static-frame dedup is armed on the drawElement path. */
|
|
@@ -404,16 +411,25 @@ export interface HfDiagnostic {
|
|
|
404
411
|
}
|
|
405
412
|
export declare function buildZeroDurationDiagnostic(diag: HfDiagnostic): string;
|
|
406
413
|
export declare function pollHfReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<void>;
|
|
407
|
-
export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number
|
|
414
|
+
export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number, options?: {
|
|
415
|
+
scriptErrorGraceMs?: number;
|
|
416
|
+
}): Promise<void>;
|
|
417
|
+
export interface IsolatedComposition {
|
|
418
|
+
compositionId: string;
|
|
419
|
+
elementId: string | null;
|
|
420
|
+
}
|
|
408
421
|
export interface SubTimelinePollOptions {
|
|
409
422
|
intervalMs?: number;
|
|
410
423
|
getScriptLoadFailures?: () => readonly string[];
|
|
411
424
|
scriptFailureGraceMs?: number;
|
|
425
|
+
getPageErrors?: () => readonly string[];
|
|
426
|
+
scriptErrorGraceMs?: number;
|
|
412
427
|
onPending?: (ids: readonly string[]) => void;
|
|
413
428
|
shouldStop?: () => boolean;
|
|
414
429
|
knownUnregisteredIds?: readonly string[];
|
|
415
430
|
}
|
|
416
|
-
export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
|
|
431
|
+
export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, getPageErrors, scriptErrorGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
|
|
432
|
+
export declare function pollVideosReady(page: Page, skipIds: readonly string[], timeoutMs: number, intervalMs?: number): Promise<boolean>;
|
|
417
433
|
/** @internal exported for unit testing only */
|
|
418
434
|
export declare function pollImagesReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<boolean>;
|
|
419
435
|
export declare function waitForImagesReady(page: Page, timeoutMs: number, onUnloaded: (sources: string) => void): Promise<boolean>;
|
|
@@ -1202,16 +1202,61 @@ async function listUnregisteredTimelineIds(page) {
|
|
|
1202
1202
|
// Builds a warning, so it must never throw: normalise a loosely typed result.
|
|
1203
1203
|
return Array.isArray(evaluated) ? evaluated.map((id) => String(id)) : [];
|
|
1204
1204
|
}
|
|
1205
|
-
|
|
1205
|
+
function isScriptFailure(session) {
|
|
1206
|
+
const outcome = session.subTimelineWaitOutcome;
|
|
1207
|
+
return outcome === "script_failure" || (outcome === "timeout" && session.pageErrors.length > 0);
|
|
1208
|
+
}
|
|
1209
|
+
/** A nested composition whose script threw gets an empty timeline, but only while another nested timeline is live. */
|
|
1210
|
+
async function isolateUnregisteredCompositions(page) {
|
|
1211
|
+
const isolated = await page.evaluate(`(function() {
|
|
1212
|
+
var timelines = (window.__timelines = window.__timelines || {});
|
|
1213
|
+
var out = [];
|
|
1214
|
+
if (!window.gsap) return out;
|
|
1215
|
+
var broken = [];
|
|
1216
|
+
var live = 0;
|
|
1217
|
+
var hosts = document.querySelectorAll("[data-composition-id]");
|
|
1218
|
+
for (var i = 0; i < hosts.length; i++) {
|
|
1219
|
+
var host = hosts[i];
|
|
1220
|
+
var id = host.getAttribute("data-composition-id");
|
|
1221
|
+
if (!id || host.hasAttribute("data-no-timeline")) continue;
|
|
1222
|
+
var isRoot = !host.parentElement || !host.parentElement.closest("[data-composition-id]");
|
|
1223
|
+
if (timelines[id]) {
|
|
1224
|
+
if (!isRoot) live++;
|
|
1225
|
+
} else if (isRoot) {
|
|
1226
|
+
return out;
|
|
1227
|
+
} else {
|
|
1228
|
+
broken.push(host);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (live === 0) return out;
|
|
1232
|
+
broken.forEach(function(host) {
|
|
1233
|
+
var tl = window.gsap.timeline({ paused: true });
|
|
1234
|
+
var duration = parseFloat(host.getAttribute("data-duration"));
|
|
1235
|
+
if (duration > 0) tl.set({}, {}, duration);
|
|
1236
|
+
var id = host.getAttribute("data-composition-id");
|
|
1237
|
+
timelines[id] = tl;
|
|
1238
|
+
out.push({ compositionId: id, elementId: host.id || host.getAttribute("data-hf-id") || null });
|
|
1239
|
+
});
|
|
1240
|
+
if (out.length && typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind();
|
|
1241
|
+
return out;
|
|
1242
|
+
})()`);
|
|
1243
|
+
return Array.isArray(isolated) ? isolated : [];
|
|
1244
|
+
}
|
|
1245
|
+
export async function waitForSubCompositionTimelines(session, page, timeoutMs, options = {}) {
|
|
1206
1246
|
const memo = session.subTimelineWaitMemo;
|
|
1207
1247
|
session.subTimelineWaitOutcome = await pollSubCompositionTimelines(page, timeoutMs, {
|
|
1208
1248
|
getScriptLoadFailures: () => session.scriptLoadFailures,
|
|
1249
|
+
getPageErrors: () => session.pageErrors,
|
|
1250
|
+
scriptErrorGraceMs: options.scriptErrorGraceMs,
|
|
1209
1251
|
onPending: (ids) => {
|
|
1210
1252
|
session.pendingTimelineIds = [...ids];
|
|
1211
1253
|
},
|
|
1212
1254
|
shouldStop: () => session.vfxFailure !== undefined,
|
|
1213
1255
|
knownUnregisteredIds: memo.unregisteredIds,
|
|
1214
1256
|
});
|
|
1257
|
+
if (isScriptFailure(session) && session.scriptLoadFailures.length === 0) {
|
|
1258
|
+
session.isolatedCompositions = await isolateUnregisteredCompositions(page);
|
|
1259
|
+
}
|
|
1215
1260
|
// Only a clean full-length timeout is replayed: a VFX stop ends early, and page or script
|
|
1216
1261
|
// errors must reach each session so it can still classify the wait as a script failure.
|
|
1217
1262
|
const cleanTimeout = session.subTimelineWaitOutcome === "timeout" &&
|
|
@@ -1222,7 +1267,7 @@ export async function waitForSubCompositionTimelines(session, page, timeoutMs) {
|
|
|
1222
1267
|
memo.unregisteredIds = session.pendingTimelineIds ?? [];
|
|
1223
1268
|
}
|
|
1224
1269
|
}
|
|
1225
|
-
export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
|
|
1270
|
+
export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, getPageErrors, scriptErrorGraceMs = 5_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
|
|
1226
1271
|
// Hosts may opt out of the timeline wait with `data-no-timeline` —
|
|
1227
1272
|
// compositions driven purely by CSS animations / rAF (the render-compat
|
|
1228
1273
|
// contract) never register window.__timelines[id], and without the opt-out
|
|
@@ -1244,6 +1289,8 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
|
|
|
1244
1289
|
const deadline = start + timeoutMs;
|
|
1245
1290
|
let ready = false;
|
|
1246
1291
|
let scriptFailureBail = false;
|
|
1292
|
+
// A page error is given its grace from when it first appeared, not from when the poll began.
|
|
1293
|
+
let errorSeenAt;
|
|
1247
1294
|
for (;;) {
|
|
1248
1295
|
ready = Boolean(await page.evaluate(expression));
|
|
1249
1296
|
if (ready || shouldStop?.())
|
|
@@ -1252,12 +1299,18 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
|
|
|
1252
1299
|
if (now >= deadline)
|
|
1253
1300
|
break;
|
|
1254
1301
|
const failures = getScriptLoadFailures?.() ?? [];
|
|
1255
|
-
|
|
1302
|
+
const errors = getPageErrors?.() ?? [];
|
|
1303
|
+
const failed = failures.length > 0 ? failures : errors;
|
|
1304
|
+
const graceMs = failures.length > 0 ? scriptFailureGraceMs : scriptErrorGraceMs;
|
|
1305
|
+
if (failed.length > 0)
|
|
1306
|
+
errorSeenAt ??= now;
|
|
1307
|
+
const sinceFailure = failures.length > 0 ? now - start : now - (errorSeenAt ?? now);
|
|
1308
|
+
if (failed.length > 0 && sinceFailure >= graceMs) {
|
|
1256
1309
|
scriptFailureBail = true;
|
|
1257
1310
|
console.warn(`[FrameCapture] Sub-composition timeline wait cut short after ${now - start}ms: ` +
|
|
1258
|
-
`script
|
|
1311
|
+
`script(s) failed (${failed.join(", ")}), so ` +
|
|
1259
1312
|
`the timeline registration they carry can never arrive. ` +
|
|
1260
|
-
`Fix the script
|
|
1313
|
+
`Fix the script.`);
|
|
1261
1314
|
break;
|
|
1262
1315
|
}
|
|
1263
1316
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
@@ -1294,11 +1347,13 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
|
|
|
1294
1347
|
}
|
|
1295
1348
|
return scriptFailureBail ? "script_failure" : "timeout";
|
|
1296
1349
|
}
|
|
1297
|
-
async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
|
|
1350
|
+
export async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
|
|
1298
1351
|
const check = async () => {
|
|
1299
1352
|
return Boolean(await page.evaluate((skipIdList) => {
|
|
1300
1353
|
const skip = new Set(skipIdList);
|
|
1301
|
-
const vids = Array.from(document.querySelectorAll("video"))
|
|
1354
|
+
const vids = Array.from(document.querySelectorAll("video"))
|
|
1355
|
+
.filter((v) => !skip.has(v.id))
|
|
1356
|
+
.filter((v) => !v.closest("[data-hidden]"));
|
|
1302
1357
|
return (vids.length === 0 ||
|
|
1303
1358
|
vids.every((v) => {
|
|
1304
1359
|
const ve = v;
|
|
@@ -1449,16 +1504,33 @@ export function recordSubTimelineWarning(session, timeoutMs) {
|
|
|
1449
1504
|
const outcome = session.subTimelineWaitOutcome;
|
|
1450
1505
|
if (outcome === "ready" || !outcome)
|
|
1451
1506
|
return;
|
|
1452
|
-
const threwThenTimedOut =
|
|
1453
|
-
const scriptFailure =
|
|
1507
|
+
const threwThenTimedOut = session.pageErrors.length > 0 && session.scriptLoadFailures.length === 0;
|
|
1508
|
+
const scriptFailure = isScriptFailure(session);
|
|
1454
1509
|
const sources = [...session.scriptLoadFailures, ...session.pageErrors];
|
|
1455
1510
|
const pending = session.pendingTimelineIds ?? [];
|
|
1456
1511
|
const pendingSuffix = pending.length > 0 ? ` (still unregistered: ${pending.join(", ")})` : "";
|
|
1512
|
+
const isolated = session.isolatedCompositions ?? [];
|
|
1513
|
+
if (isolated.length > 0) {
|
|
1514
|
+
recordCaptureWarnings(session, [
|
|
1515
|
+
{
|
|
1516
|
+
code: "script_error_isolated",
|
|
1517
|
+
message: `A composition script failed, so its animation was skipped and the render went on: ` +
|
|
1518
|
+
`${isolated.map((c) => c.elementId ?? c.compositionId).join(", ")} (${sources.join(", ")}).`,
|
|
1519
|
+
details: {
|
|
1520
|
+
sources,
|
|
1521
|
+
pendingCompositionIds: [...pending],
|
|
1522
|
+
isolatedElements: isolated,
|
|
1523
|
+
failureOwner: "user",
|
|
1524
|
+
},
|
|
1525
|
+
},
|
|
1526
|
+
]);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1457
1529
|
recordCaptureWarnings(session, [
|
|
1458
1530
|
{
|
|
1459
1531
|
code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
|
|
1460
1532
|
message: threwThenTimedOut
|
|
1461
|
-
? `A composition script threw
|
|
1533
|
+
? `A composition script threw before its timeline registered` +
|
|
1462
1534
|
`${pendingSuffix} (${sources.join(", ")}). Fix the error; a composition animated by ` +
|
|
1463
1535
|
`CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.`
|
|
1464
1536
|
: scriptFailure
|
|
@@ -1802,6 +1874,7 @@ export async function initializeSession(session) {
|
|
|
1802
1874
|
const skip = new Set(skipIdList);
|
|
1803
1875
|
return Array.from(document.querySelectorAll("video"))
|
|
1804
1876
|
.filter((v) => !skip.has(v.id))
|
|
1877
|
+
.filter((v) => !v.closest("[data-hidden]"))
|
|
1805
1878
|
.filter((v) => v.readyState < 2 && !v.error)
|
|
1806
1879
|
.map((v) => v.src || v.getAttribute("src") || "(no src)")
|
|
1807
1880
|
.join(", ");
|
|
@@ -1914,6 +1987,7 @@ export async function initializeSession(session) {
|
|
|
1914
1987
|
const skip = new Set(skipIdList);
|
|
1915
1988
|
return Array.from(document.querySelectorAll("video"))
|
|
1916
1989
|
.filter((v) => !skip.has(v.id))
|
|
1990
|
+
.filter((v) => !v.closest("[data-hidden]"))
|
|
1917
1991
|
.filter((v) => v.readyState < 2 && !v.error)
|
|
1918
1992
|
.map((v) => v.src || v.getAttribute("src") || "(no src)")
|
|
1919
1993
|
.join(", ");
|
|
@@ -2925,6 +2999,50 @@ export function resolveSessionMotionBlur(session) {
|
|
|
2925
2999
|
}
|
|
2926
3000
|
return plan;
|
|
2927
3001
|
}
|
|
3002
|
+
/**
|
|
3003
|
+
* Size guard for a serial drawElement frame. A frame far below the rolling median may be a
|
|
3004
|
+
* dropped paint record, which is intermittent, so the frame is drawn once more: a byte-identical
|
|
3005
|
+
* redraw is real content (a flat or dark frame) and is kept, as the streaming drain guard in the
|
|
3006
|
+
* producer keeps it. Segmented capture reaches only this guard, so it must judge a small frame
|
|
3007
|
+
* the same way, or a render that passes in one pass fails when segmented (GH #5685). A redraw
|
|
3008
|
+
* that differs and is still small throws, and the caller restarts on a fresh screenshot page.
|
|
3009
|
+
*/
|
|
3010
|
+
async function judgeSmallDrawElementFrame(session, frameIndex, buffer, redraw) {
|
|
3011
|
+
const sizes = (session.deFrameSizes ??= []);
|
|
3012
|
+
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
|
|
3013
|
+
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
|
|
3014
|
+
const floor = Math.max(session.deAbsFloor ?? 20000, median * 0.12);
|
|
3015
|
+
let kept = buffer;
|
|
3016
|
+
if (buffer.length < floor && !session.deAcceptedSmall?.equals(buffer)) {
|
|
3017
|
+
const suspect = () => new DrawElementCaptureError(frameIndex, `suspect small frame (${buffer.length}B < ${Math.round(floor)}B)`);
|
|
3018
|
+
// Under BeginFrame control the page paints only on the frame already sent for this index,
|
|
3019
|
+
// so a second draw reads the same snapshot and cannot tell a drop from real content.
|
|
3020
|
+
if (session.beginFrameTimeTicks > 0)
|
|
3021
|
+
throw suspect();
|
|
3022
|
+
const retry = await redraw();
|
|
3023
|
+
if (retry.equals(buffer)) {
|
|
3024
|
+
// Halving the floor keeps a run of similar flat frames from redrawing every frame.
|
|
3025
|
+
session.deAbsFloor = Math.min(session.deAbsFloor ?? 20000, Math.floor(buffer.length / 2));
|
|
3026
|
+
session.deAcceptedSmall = buffer;
|
|
3027
|
+
}
|
|
3028
|
+
else if (retry.length < floor) {
|
|
3029
|
+
throw suspect();
|
|
3030
|
+
}
|
|
3031
|
+
else {
|
|
3032
|
+
kept = retry;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
if (sizes.length >= 60)
|
|
3036
|
+
sizes.shift();
|
|
3037
|
+
sizes.push(kept.length);
|
|
3038
|
+
return kept;
|
|
3039
|
+
}
|
|
3040
|
+
/** Seek to `time` again and draw the frame once more, for {@link judgeSmallDrawElementFrame}. */
|
|
3041
|
+
async function redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime) {
|
|
3042
|
+
const { page, options } = session;
|
|
3043
|
+
await prepareFrameForCapture(session, frameIndex, time, seekOptions, heldVideoTime);
|
|
3044
|
+
return captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80, true);
|
|
3045
|
+
}
|
|
2928
3046
|
/** Seek to `time` and capture one surface with the session's capture mode. */
|
|
2929
3047
|
async function captureFrameSurface(session, frameIndex, time, seekOptions, heldVideoTime) {
|
|
2930
3048
|
const { page, options } = session;
|
|
@@ -2976,20 +3094,8 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
|
|
|
2976
3094
|
session.beginFrameTimeTicks === 0);
|
|
2977
3095
|
// A tiny JPEG may be a dropped paint record. Never screenshot this page:
|
|
2978
3096
|
// its injected canvas can still hold the preceding frame's bitmap.
|
|
2979
|
-
// Restart on a fresh screenshot page even if this was a simple valid frame.
|
|
2980
3097
|
if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
|
|
2981
|
-
|
|
2982
|
-
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
|
|
2983
|
-
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
|
|
2984
|
-
const floor = Math.max(20000, median * 0.12);
|
|
2985
|
-
if (screenshotBuffer.length < floor) {
|
|
2986
|
-
throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
|
|
2987
|
-
}
|
|
2988
|
-
else {
|
|
2989
|
-
if (sizes.length >= 60)
|
|
2990
|
-
sizes.shift();
|
|
2991
|
-
sizes.push(screenshotBuffer.length);
|
|
2992
|
-
}
|
|
3098
|
+
screenshotBuffer = await judgeSmallDrawElementFrame(session, frameIndex, screenshotBuffer, () => redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime));
|
|
2993
3099
|
}
|
|
2994
3100
|
}
|
|
2995
3101
|
catch (err) {
|
|
@@ -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/types.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type SubTimelineWaitOutcome = "ready" | "timeout" | "script_failure";
|
|
|
22
22
|
export interface SubTimelineWaitMemo {
|
|
23
23
|
unregisteredIds?: readonly string[];
|
|
24
24
|
}
|
|
25
|
-
export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "vfx_failure" | "live_map_detected";
|
|
25
|
+
export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
|
|
26
26
|
/** Structured correctness warning produced while preparing a capture session. */
|
|
27
27
|
export interface CaptureWarning {
|
|
28
28
|
code: CaptureWarningCode;
|
|
@@ -31,6 +31,10 @@ export interface CaptureWarning {
|
|
|
31
31
|
mediaType?: "image" | "video" | "audio";
|
|
32
32
|
sources?: string[];
|
|
33
33
|
pendingCompositionIds?: string[];
|
|
34
|
+
isolatedElements?: Array<{
|
|
35
|
+
compositionId: string;
|
|
36
|
+
elementId: string | null;
|
|
37
|
+
}>;
|
|
34
38
|
timeoutMs?: number;
|
|
35
39
|
audioFailures?: AudioFailureDiagnostic[];
|
|
36
40
|
failureReasons?: string[];
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface AudioMetadata {
|
|
|
51
51
|
bitrate?: number;
|
|
52
52
|
/** Seconds after the file's media time 0 at which its last audio or video stream starts. */
|
|
53
53
|
latestStreamLeadSeconds?: number;
|
|
54
|
+
audioStreamLeadSeconds?: number;
|
|
55
|
+
audioStreamCount?: number;
|
|
54
56
|
}
|
|
55
57
|
interface FFProbeStream {
|
|
56
58
|
codec_type: string;
|
|
@@ -98,6 +100,8 @@ export interface MediaProbeProfile {
|
|
|
98
100
|
hasAudioStream: boolean;
|
|
99
101
|
visualKind: "none" | "still" | "moving";
|
|
100
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;
|
|
101
105
|
export declare function mediaFileIdentity(filePath: string): string | null;
|
|
102
106
|
export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
|
|
103
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,47 +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
|
-
};
|
|
778
|
-
})();
|
|
779
|
-
if (options?.signal)
|
|
780
|
-
return probePromise;
|
|
781
|
-
writeMetadataCache(audioMetadataCache, filePath, identity, probePromise);
|
|
768
|
+
const probePromise = probeAudioMetadata(filePath, signal);
|
|
769
|
+
writeMetadataCache(cache, filePath, identity, probePromise);
|
|
782
770
|
return probePromise;
|
|
783
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
|
+
}
|
|
784
801
|
/** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
|
|
785
802
|
function audioVideoStreamStarts(output) {
|
|
786
803
|
return (output?.streams ?? [])
|
|
@@ -858,8 +875,7 @@ async function probeDecodedAudioDuration(filePath, sampleRate, startSeconds, con
|
|
|
858
875
|
return lastFrame.timestamp + lastFrame.nbSamples / sampleRate - startSeconds;
|
|
859
876
|
}
|
|
860
877
|
const keyframeCache = new Map();
|
|
861
|
-
/**
|
|
862
|
-
* audio/video desync — the sole threshold for `isProblematic` below. */
|
|
878
|
+
/** Advisory threshold for seeking cost; GOP length alone does not establish incorrect output. */
|
|
863
879
|
const PROBLEMATIC_KEYFRAME_INTERVAL_SECONDS = 2;
|
|
864
880
|
/**
|
|
865
881
|
* Checks keyframe intervals in a video stream; intervals over the threshold below flag `isProblematic`.
|
|
@@ -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/core": "^0.8.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.192",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.192"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|