@hyperframes/engine 0.8.191 → 0.8.193
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 -2
- package/dist/index.js +3 -2
- package/dist/services/aacEncode.js +27 -0
- package/dist/services/audioMixer.js +105 -18
- package/dist/services/audioMixer.types.d.ts +2 -0
- package/dist/services/audioVolumeEnvelope.js +3 -2
- package/dist/services/browserManager.js +4 -0
- package/dist/services/captureFailure.d.ts +3 -0
- package/dist/services/captureFailure.js +29 -28
- package/dist/services/chunkEncoder.js +1 -1
- package/dist/services/extractionCache.js +5 -4
- package/dist/services/frameCapture.js +4 -1
- package/dist/services/parallelCoordinator.d.ts +2 -0
- package/dist/services/parallelCoordinator.js +10 -2
- package/dist/services/softwareEncoderThreads.d.ts +2 -1
- package/dist/services/softwareEncoderThreads.js +4 -2
- package/dist/services/streamingEncoder.js +1 -1
- package/dist/services/videoFrameExtractor.d.ts +15 -2
- package/dist/services/videoFrameExtractor.js +70 -19
- package/dist/utils/ffprobe.d.ts +16 -0
- package/dist/utils/ffprobe.js +184 -39
- package/dist/utils/htmlTemplate.js +1 -1
- package/dist/utils/processRss.js +5 -2
- package/dist/utils/renameWithRetry.d.ts +12 -0
- package/dist/utils/renameWithRetry.js +46 -0
- 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, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, 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";
|
|
@@ -72,6 +72,7 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
|
72
72
|
export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
|
|
73
73
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
74
74
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
75
|
+
export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
|
|
75
76
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
76
77
|
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
|
|
77
78
|
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.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, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, 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";
|
|
@@ -76,6 +76,7 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
|
76
76
|
export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
|
|
77
77
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
78
78
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
79
|
+
export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
|
|
79
80
|
// drawElement self-verify comparison — shared by the streaming drain
|
|
80
81
|
// (producer) and the parallel disk-path verify (parallelCoordinator).
|
|
81
82
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* AAC for the mixed 48 kHz audio. One encoder is single-threaded (about 60x real time),
|
|
3
3
|
* so long audio is encoded as frame-aligned segments in parallel and their frames joined.
|
|
4
4
|
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
5
6
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
8
|
+
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
|
7
9
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
8
10
|
const SAMPLE_RATE = 48_000;
|
|
9
11
|
const FRAME_SAMPLES = 1024;
|
|
@@ -48,8 +50,33 @@ function planSegments(totalDuration, count) {
|
|
|
48
50
|
return { trim, keepFrom: cut - firstFrame, keepTo: next - firstFrame };
|
|
49
51
|
});
|
|
50
52
|
}
|
|
53
|
+
const settsDurationSupport = new Map();
|
|
54
|
+
/** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */
|
|
55
|
+
function supportsSettsDuration(binary = getFfmpegBinary()) {
|
|
56
|
+
let supported = settsDurationSupport.get(binary);
|
|
57
|
+
if (!supported) {
|
|
58
|
+
supported = new Promise((resolve) => {
|
|
59
|
+
execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => {
|
|
60
|
+
// A binary that cannot even print help fails the encode with its own error, not this one.
|
|
61
|
+
resolve(error !== null || stdout.includes("duration"));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
settsDurationSupport.set(binary, supported);
|
|
65
|
+
}
|
|
66
|
+
return supported;
|
|
67
|
+
}
|
|
68
|
+
function encodeInOnePass(input, outputPath, { signal, timeout, filter }) {
|
|
69
|
+
const filters = [filter, `aresample=${SAMPLE_RATE}`].filter(Boolean).join(",");
|
|
70
|
+
return runFfmpeg(["-i", input, "-af", filters, ...AAC_ARGS, "-y", outputPath], {
|
|
71
|
+
signal,
|
|
72
|
+
timeout,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
51
75
|
/** `input`, after `filter`, is audio of exactly `totalDuration`. */
|
|
52
76
|
export async function encodeAac(input, outputPath, totalDuration, { signal, timeout, filter, segments: count = aacSegmentCount(totalDuration), }) {
|
|
77
|
+
if (!(await supportsSettsDuration())) {
|
|
78
|
+
return encodeInOnePass(input, outputPath, { signal, timeout, filter });
|
|
79
|
+
}
|
|
53
80
|
const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-"));
|
|
54
81
|
try {
|
|
55
82
|
const segments = planSegments(totalDuration, count).map((segment, k) => ({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// fallow-ignore-file complexity code-duplication
|
|
2
2
|
import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
|
|
3
|
-
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync,
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
|
|
4
|
+
import { renameWithRetry } from "../utils/renameWithRetry.js";
|
|
4
5
|
import { join, dirname, isAbsolute, relative } from "path";
|
|
5
6
|
import { parseHTML } from "linkedom";
|
|
6
7
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
@@ -133,6 +134,8 @@ function legacyFilterScriptOptionIsUnsupported(stderr) {
|
|
|
133
134
|
*/
|
|
134
135
|
// Caps the ffmpeg runs that prepare clips; all of a long mix's clips at once starved the host (200 clips, 200 ffmpeg).
|
|
135
136
|
const inPrepareSlot = createConcurrencyLimit(Math.max(2, Math.floor(cpus().length / 2)));
|
|
137
|
+
// A clip read from a URL spends its time waiting on the network, so it needs no CPU-sized limit.
|
|
138
|
+
const inRemotePrepareSlot = createConcurrencyLimit(16);
|
|
136
139
|
const MAX_VOLUME_SEGMENTS = 32;
|
|
137
140
|
/**
|
|
138
141
|
* Volume delta below which a keyframe is collinear enough to drop. Kept tight
|
|
@@ -428,11 +431,13 @@ export function parseAudioElements(html) {
|
|
|
428
431
|
const startCache = new Map();
|
|
429
432
|
const visiting = new Set();
|
|
430
433
|
const resolveStart = (el) => el.getAttribute("data-start") ? resolveReferencedStart(document, el, startCache, visiting) : 0;
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
434
|
+
// Same rule as core's compileTimingAttrs: data-end, else start + data-duration, else 0 (unknown).
|
|
435
|
+
const resolveEnd = (el) => {
|
|
436
|
+
const end = parseStrictFiniteTimingNumber(el.getAttribute("data-end"));
|
|
437
|
+
if (end != null)
|
|
438
|
+
return end;
|
|
439
|
+
const duration = parseStrictFiniteTimingNumber(el.getAttribute("data-duration"));
|
|
440
|
+
return duration != null && duration > 0 ? resolveStart(el) + duration : 0;
|
|
436
441
|
};
|
|
437
442
|
const isHidden = isSelfOrAncestorHidden;
|
|
438
443
|
// Resolved once per parse. A group element carrying `data-hidden` drops
|
|
@@ -454,9 +459,10 @@ export function parseAudioElements(html) {
|
|
|
454
459
|
id,
|
|
455
460
|
src,
|
|
456
461
|
start: resolveStart(el),
|
|
457
|
-
end:
|
|
462
|
+
end: resolveEnd(el),
|
|
458
463
|
mediaStart: readMediaStart(el),
|
|
459
464
|
playbackRate: readElementRateSpec(el),
|
|
465
|
+
loop: el.getAttribute("loop") !== null,
|
|
460
466
|
layer: layerAttr ? parseInt(layerAttr) : 0,
|
|
461
467
|
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
|
|
462
468
|
...(fades.fadeIn > 0 ? { fadeIn: fades.fadeIn } : {}),
|
|
@@ -506,14 +512,14 @@ const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
|
506
512
|
const SIDECAR_WARM_UP_SECONDS = 1;
|
|
507
513
|
/** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
|
|
508
514
|
* 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) {
|
|
515
|
+
async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl, options) {
|
|
510
516
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
511
517
|
const outputDir = dirname(outputPath);
|
|
512
518
|
if (!existsSync(outputDir))
|
|
513
519
|
mkdirSync(outputDir, { recursive: true });
|
|
514
520
|
const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
|
|
515
521
|
const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
|
|
516
|
-
// A failed local probe keeps FFmpeg's
|
|
522
|
+
// A failed non-looping local probe keeps FFmpeg's channel conversion and input seek; a URL probe
|
|
517
523
|
// is retried once, then fails: a blip must not change how the next, successful read is mixed.
|
|
518
524
|
const readsUrl = isHttpUrl(srcPath);
|
|
519
525
|
const metadata = readsUrl
|
|
@@ -522,7 +528,53 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
522
528
|
throw error;
|
|
523
529
|
return extractAudioMetadata(srcPath, { signal });
|
|
524
530
|
})
|
|
525
|
-
: await extractAudioMetadata(srcPath).catch(() =>
|
|
531
|
+
: await extractAudioMetadata(srcPath).catch((error) => {
|
|
532
|
+
if (options?.loop)
|
|
533
|
+
throw error;
|
|
534
|
+
return null;
|
|
535
|
+
});
|
|
536
|
+
const loopDuration = (metadata?.durationSeconds ?? 0) - mediaStart;
|
|
537
|
+
if (options?.loop && loopDuration > 0 && sourceDuration > loopDuration) {
|
|
538
|
+
const cycleDir = mkdtempSync(join(outputDir, ".audio-loop-"));
|
|
539
|
+
const cyclePath = join(cycleDir, "cycle.wav");
|
|
540
|
+
try {
|
|
541
|
+
const cycle = await extractAudioSegment(srcPath, cyclePath, stage, mediaStart, loopDuration, 1, signal, config, audioSidecarUrl, { padToDuration: true });
|
|
542
|
+
if (!cycle.success)
|
|
543
|
+
return { ...cycle, outputPath };
|
|
544
|
+
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null);
|
|
545
|
+
const result = await runFfmpeg([
|
|
546
|
+
"-stream_loop",
|
|
547
|
+
"-1",
|
|
548
|
+
"-t",
|
|
549
|
+
String(sourceDuration),
|
|
550
|
+
"-i",
|
|
551
|
+
cyclePath,
|
|
552
|
+
"-vn",
|
|
553
|
+
"-acodec",
|
|
554
|
+
"pcm_s16le",
|
|
555
|
+
"-ar",
|
|
556
|
+
"48000",
|
|
557
|
+
...outputArgs,
|
|
558
|
+
"-t",
|
|
559
|
+
String(duration),
|
|
560
|
+
"-y",
|
|
561
|
+
outputPath,
|
|
562
|
+
], { signal, timeout: ffmpegProcessTimeout });
|
|
563
|
+
const failure = result.success
|
|
564
|
+
? undefined
|
|
565
|
+
: ffmpegFailure(stage, result, ffmpegProcessTimeout);
|
|
566
|
+
return {
|
|
567
|
+
success: result.success,
|
|
568
|
+
outputPath,
|
|
569
|
+
durationMs: cycle.durationMs + result.durationMs,
|
|
570
|
+
error: failure?.detail,
|
|
571
|
+
failure,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
rmSync(cycleDir, { recursive: true, force: true });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
526
578
|
const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
|
|
527
579
|
const start = formatFilterNumber(mediaStart);
|
|
528
580
|
const timestampTrim = inputSeek
|
|
@@ -530,7 +582,12 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
530
582
|
: `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
|
|
531
583
|
const fromSidecar = audioSidecarUrl !== undefined && inputSeek && normalizedPlaybackRate === 1;
|
|
532
584
|
const warmUp = fromSidecar ? Math.min(SIDECAR_WARM_UP_SECONDS, mediaStart) : 0;
|
|
533
|
-
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration,
|
|
585
|
+
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, [
|
|
586
|
+
warmUp > 0 ? `atrim=start=${warmUp},asetpts=PTS-STARTPTS` : timestampTrim,
|
|
587
|
+
...(options?.padToDuration ? ["apad"] : []),
|
|
588
|
+
]
|
|
589
|
+
.filter(Boolean)
|
|
590
|
+
.join(",") || null);
|
|
534
591
|
const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
|
|
535
592
|
const args = [
|
|
536
593
|
...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
|
|
@@ -542,7 +599,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
542
599
|
readFrom,
|
|
543
600
|
];
|
|
544
601
|
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
|
|
545
|
-
if (normalizedPlaybackRate !== 1)
|
|
602
|
+
if (normalizedPlaybackRate !== 1 || options?.padToDuration)
|
|
546
603
|
args.push("-t", String(duration));
|
|
547
604
|
args.push("-y", outputPath);
|
|
548
605
|
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
|
@@ -575,6 +632,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
575
632
|
failure,
|
|
576
633
|
};
|
|
577
634
|
}
|
|
635
|
+
function isRemoteElement(element, baseDir, compiledDir, config) {
|
|
636
|
+
if (isHttpUrl(element.src))
|
|
637
|
+
return true;
|
|
638
|
+
if (!config?.urlByAbsentLocalPath?.size)
|
|
639
|
+
return false;
|
|
640
|
+
try {
|
|
641
|
+
return config.urlByAbsentLocalPath.has(resolveProjectRelativeSrc(element.src, baseDir, compiledDir));
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
578
647
|
async function generateSilence(outputPath, duration, signal, config) {
|
|
579
648
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
580
649
|
const outputDir = dirname(outputPath);
|
|
@@ -945,7 +1014,7 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
|
|
|
945
1014
|
const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
|
|
946
1015
|
if (!summed.success)
|
|
947
1016
|
return summed;
|
|
948
|
-
|
|
1017
|
+
renameWithRetry(summed.track.srcPath, outputPath);
|
|
949
1018
|
return { success: true, degradedAutomation: summed.degradedAutomation };
|
|
950
1019
|
}
|
|
951
1020
|
finally {
|
|
@@ -991,15 +1060,18 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
|
|
|
991
1060
|
*/
|
|
992
1061
|
async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
|
|
993
1062
|
const sumPath = join(workDir, "master-sum.wav");
|
|
1063
|
+
const mixStart = Date.now();
|
|
994
1064
|
const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config);
|
|
995
1065
|
if (!sum.success)
|
|
996
1066
|
return { ...sum, outputPath };
|
|
1067
|
+
const masterStart = Date.now();
|
|
997
1068
|
const encoderInput = masterChain
|
|
998
1069
|
? (await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
|
|
999
1070
|
trackId: "master",
|
|
1000
1071
|
signal,
|
|
1001
1072
|
})).path
|
|
1002
1073
|
: sumPath;
|
|
1074
|
+
const encodeStart = Date.now();
|
|
1003
1075
|
const encoded = await encodeAac(encoderInput, outputPath, totalDuration, {
|
|
1004
1076
|
signal,
|
|
1005
1077
|
timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
|
|
@@ -1016,8 +1088,17 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
|
|
|
1016
1088
|
};
|
|
1017
1089
|
}
|
|
1018
1090
|
if (config?.keepEncoderInputAt)
|
|
1019
|
-
|
|
1020
|
-
return {
|
|
1091
|
+
renameWithRetry(encoderInput, config.keepEncoderInputAt);
|
|
1092
|
+
return {
|
|
1093
|
+
...sum,
|
|
1094
|
+
outputPath,
|
|
1095
|
+
durationMs: sum.durationMs + encoded.durationMs,
|
|
1096
|
+
phaseMs: {
|
|
1097
|
+
mix: masterStart - mixStart,
|
|
1098
|
+
master: encodeStart - masterStart,
|
|
1099
|
+
encode: Date.now() - encodeStart,
|
|
1100
|
+
},
|
|
1101
|
+
};
|
|
1021
1102
|
}
|
|
1022
1103
|
export async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config, compiledDir) {
|
|
1023
1104
|
const startMs = Date.now();
|
|
@@ -1053,7 +1134,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1053
1134
|
else
|
|
1054
1135
|
signal.addEventListener("abort", () => internalController.abort(), { once: true });
|
|
1055
1136
|
}
|
|
1056
|
-
await Promise.all(elements.map((element, index) =>
|
|
1137
|
+
await Promise.all(elements.map((element, index) => (isRemoteElement(element, baseDir, compiledDir, config)
|
|
1138
|
+
? inRemotePrepareSlot
|
|
1139
|
+
: inPrepareSlot)(async () => {
|
|
1057
1140
|
if (effectiveSignal.aborted) {
|
|
1058
1141
|
failures.push({
|
|
1059
1142
|
stage: "cancelled",
|
|
@@ -1115,6 +1198,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1115
1198
|
}
|
|
1116
1199
|
// Fallback: if no duration was specified, probe the actual file
|
|
1117
1200
|
if (element.end - element.start <= 0) {
|
|
1201
|
+
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
1202
|
let metadata;
|
|
1119
1203
|
try {
|
|
1120
1204
|
metadata = await extractAudioMetadata(srcPath, readInPlace ? { signal: effectiveSignal } : undefined);
|
|
@@ -1131,7 +1215,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1131
1215
|
let audioSrcPath = srcPath;
|
|
1132
1216
|
if (element.type === "video") {
|
|
1133
1217
|
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);
|
|
1218
|
+
const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl, { loop: element.loop });
|
|
1135
1219
|
if (!extractResult.success) {
|
|
1136
1220
|
failures.push(extractResult.failure
|
|
1137
1221
|
? { ...extractResult.failure, elementId: element.id }
|
|
@@ -1149,7 +1233,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1149
1233
|
}
|
|
1150
1234
|
else {
|
|
1151
1235
|
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);
|
|
1236
|
+
const prepResult = await extractAudioSegment(srcPath, trimmedPath, "prepare", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, undefined, { loop: element.loop });
|
|
1153
1237
|
if (!prepResult.success) {
|
|
1154
1238
|
failures.push(prepResult.failure
|
|
1155
1239
|
? { ...prepResult.failure, elementId: element.id }
|
|
@@ -1296,6 +1380,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1296
1380
|
failures,
|
|
1297
1381
|
};
|
|
1298
1382
|
};
|
|
1383
|
+
const prepareMs = Date.now() - startMs;
|
|
1299
1384
|
if (failures.length > 0)
|
|
1300
1385
|
return bail();
|
|
1301
1386
|
// Parsed before any group is mixed, so a malformed master chain fails fast and
|
|
@@ -1415,6 +1500,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1415
1500
|
}
|
|
1416
1501
|
if (failures.length > 0)
|
|
1417
1502
|
return bail();
|
|
1503
|
+
const groupsMs = Date.now() - startMs - prepareMs;
|
|
1418
1504
|
let mixResult;
|
|
1419
1505
|
try {
|
|
1420
1506
|
mixResult =
|
|
@@ -1434,6 +1520,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1434
1520
|
return {
|
|
1435
1521
|
...mixResult,
|
|
1436
1522
|
durationMs: Date.now() - startMs,
|
|
1523
|
+
phaseMs: { prepare: prepareMs, groups: groupsMs, ...mixResult.phaseMs },
|
|
1437
1524
|
// Both, when both degraded. `mixResult.error ?? degradedNote` reported only
|
|
1438
1525
|
// the outer mix and dropped the one that names which GROUPS lost their
|
|
1439
1526
|
// members' automation — two different losses, and the operator needs to
|
|
@@ -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[];
|
|
@@ -80,4 +81,5 @@ export interface MixResult {
|
|
|
80
81
|
tracksProcessed: number;
|
|
81
82
|
error?: string;
|
|
82
83
|
failures?: AudioProcessingFailure[];
|
|
84
|
+
phaseMs?: Partial<Record<"prepare" | "groups" | "mix" | "master" | "encode", number>>;
|
|
83
85
|
}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* `extractAudioSegment`). Anything else is rejected so
|
|
15
15
|
* the caller can fall back to the expression path rather than corrupting audio.
|
|
16
16
|
*/
|
|
17
|
-
import { mkdtempSync, readFileSync,
|
|
17
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
18
|
+
import { renameWithRetry } from "../utils/renameWithRetry.js";
|
|
18
19
|
import { dirname, join } from "path";
|
|
19
20
|
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
|
20
21
|
import { riffChunks, wavFormatTag } from "./wavChunks.js";
|
|
@@ -131,7 +132,7 @@ export function applyVolumeEnvelopeToWav(wavPath, keyframes, trackStart, baseVol
|
|
|
131
132
|
stagingDir = mkdtempSync(join(dirname(wavPath), ".hf-volume-"));
|
|
132
133
|
const tempPath = join(stagingDir, "audio.wav");
|
|
133
134
|
writeFileSync(tempPath, buffer, { flag: "wx" });
|
|
134
|
-
|
|
135
|
+
renameWithRetry(tempPath, wavPath);
|
|
135
136
|
return true;
|
|
136
137
|
}
|
|
137
138
|
catch {
|
|
@@ -769,6 +769,7 @@ function getLowMemoryFlags() {
|
|
|
769
769
|
}
|
|
770
770
|
const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
|
|
771
771
|
const WEBGPU_FLAG = "--enable-unsafe-webgpu";
|
|
772
|
+
const DISABLE_DXC_FLAG = "--disable-dawn-features=use_dxc";
|
|
772
773
|
/**
|
|
773
774
|
* Whether this launch renders WebGPU on SwiftShader: the composition needs it, the render opted in, no GPU is in use.
|
|
774
775
|
* SwiftShader draws canvas WebGPU only via Vulkan with GPU compositing on, and not at all under --disable-gpu.
|
|
@@ -839,6 +840,9 @@ export function buildChromeArgs(options, config) {
|
|
|
839
840
|
];
|
|
840
841
|
if (browserGpuMode !== "software" || options.requiresWebGpu) {
|
|
841
842
|
chromeArgs.push(WEBGPU_FLAG);
|
|
843
|
+
if (platform === "win32") {
|
|
844
|
+
chromeArgs.push(DISABLE_DXC_FLAG);
|
|
845
|
+
}
|
|
842
846
|
}
|
|
843
847
|
if (softwareWebGpu)
|
|
844
848
|
chromeArgs.push("--use-vulkan=swiftshader");
|
|
@@ -10,16 +10,19 @@ export declare class CaptureFailure extends Error {
|
|
|
10
10
|
readonly kind: CaptureFailureKind;
|
|
11
11
|
readonly cause: unknown;
|
|
12
12
|
readonly workerDiagnostics: readonly CaptureWorkerDiagnostic[];
|
|
13
|
+
readonly pageErrorLines: readonly string[];
|
|
13
14
|
constructor(input: {
|
|
14
15
|
kind: CaptureFailureKind;
|
|
15
16
|
message: string;
|
|
16
17
|
cause?: unknown;
|
|
17
18
|
workerDiagnostics?: readonly CaptureWorkerDiagnostic[];
|
|
19
|
+
pageErrorLines?: readonly string[];
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
22
|
export declare function classifyCaptureFailure(error: unknown, options?: {
|
|
21
23
|
signal?: AbortSignal;
|
|
22
24
|
workerDiagnostics?: readonly CaptureWorkerDiagnostic[];
|
|
25
|
+
pageErrorLines?: readonly string[];
|
|
23
26
|
}): CaptureFailure;
|
|
24
27
|
export declare function isTransientBrowserError(error: unknown): boolean;
|
|
25
28
|
export declare function isMemoryExhaustionError(error: unknown): boolean;
|
|
@@ -2,12 +2,14 @@ export class CaptureFailure extends Error {
|
|
|
2
2
|
kind;
|
|
3
3
|
cause;
|
|
4
4
|
workerDiagnostics;
|
|
5
|
+
pageErrorLines;
|
|
5
6
|
constructor(input) {
|
|
6
7
|
super(input.message);
|
|
7
8
|
this.name = "CaptureFailure";
|
|
8
9
|
this.kind = input.kind;
|
|
9
10
|
this.cause = input.cause;
|
|
10
11
|
this.workerDiagnostics = Object.freeze((input.workerDiagnostics ?? []).map((diagnostic) => Object.freeze({ ...diagnostic, lines: Object.freeze([...diagnostic.lines]) })));
|
|
12
|
+
this.pageErrorLines = Object.freeze([...(input.pageErrorLines ?? [])]);
|
|
11
13
|
if (input.cause instanceof Error && input.cause.stack)
|
|
12
14
|
this.stack = input.cause.stack;
|
|
13
15
|
}
|
|
@@ -101,41 +103,40 @@ function ioError(error, message) {
|
|
|
101
103
|
BROKEN_PIPE_MESSAGE.test(message) ||
|
|
102
104
|
hasIoOperationFailure(message));
|
|
103
105
|
}
|
|
106
|
+
const ORDERED_KIND_RULES = [
|
|
107
|
+
[
|
|
108
|
+
"memory_exhaustion",
|
|
109
|
+
(message) => BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) ||
|
|
110
|
+
BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) ||
|
|
111
|
+
matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS),
|
|
112
|
+
],
|
|
113
|
+
["verification", (message) => matchesAny(message, VERIFICATION_ERROR_PATTERNS)],
|
|
114
|
+
["protocol_timeout", (message) => matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)],
|
|
115
|
+
["transient_browser", (message) => matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)],
|
|
116
|
+
["authoring", (message) => matchesAny(message, AUTHORING_ERROR_PATTERNS)],
|
|
117
|
+
];
|
|
118
|
+
function classifyKind(error, message, signal) {
|
|
119
|
+
if (signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) {
|
|
120
|
+
return "cancelled";
|
|
121
|
+
}
|
|
122
|
+
const rule = ORDERED_KIND_RULES.find(([, matches]) => matches(message));
|
|
123
|
+
if (rule)
|
|
124
|
+
return rule[0];
|
|
125
|
+
return ioError(error, message) ? "io" : "authoring";
|
|
126
|
+
}
|
|
104
127
|
export function classifyCaptureFailure(error, options = {}) {
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
const prior = error instanceof CaptureFailure ? error : undefined;
|
|
129
|
+
if (prior && !(options.workerDiagnostics || options.pageErrorLines || options.signal?.aborted)) {
|
|
130
|
+
return prior;
|
|
107
131
|
}
|
|
108
132
|
const message = messageOf(error);
|
|
109
|
-
|
|
110
|
-
if (options.signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) {
|
|
111
|
-
kind = "cancelled";
|
|
112
|
-
}
|
|
113
|
-
else if (BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) ||
|
|
114
|
-
BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) ||
|
|
115
|
-
matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS)) {
|
|
116
|
-
kind = "memory_exhaustion";
|
|
117
|
-
}
|
|
118
|
-
else if (matchesAny(message, VERIFICATION_ERROR_PATTERNS)) {
|
|
119
|
-
kind = "verification";
|
|
120
|
-
}
|
|
121
|
-
else if (matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)) {
|
|
122
|
-
kind = "protocol_timeout";
|
|
123
|
-
}
|
|
124
|
-
else if (matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)) {
|
|
125
|
-
kind = "transient_browser";
|
|
126
|
-
}
|
|
127
|
-
else if (matchesAny(message, AUTHORING_ERROR_PATTERNS)) {
|
|
128
|
-
kind = "authoring";
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
kind = ioError(error, message) ? "io" : "authoring";
|
|
132
|
-
}
|
|
133
|
+
const kind = classifyKind(error, message, options.signal);
|
|
133
134
|
return new CaptureFailure({
|
|
134
135
|
kind,
|
|
135
136
|
message,
|
|
136
137
|
cause: error,
|
|
137
|
-
workerDiagnostics: options.workerDiagnostics ??
|
|
138
|
-
|
|
138
|
+
workerDiagnostics: options.workerDiagnostics ?? prior?.workerDiagnostics,
|
|
139
|
+
pageErrorLines: options.pageErrorLines ?? prior?.pageErrorLines,
|
|
139
140
|
});
|
|
140
141
|
}
|
|
141
142
|
export function isTransientBrowserError(error) {
|
|
@@ -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
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* become inert and can be gc'd by the caller.
|
|
33
33
|
*/
|
|
34
34
|
import { createHash, randomUUID } from "node:crypto";
|
|
35
|
-
import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync,
|
|
35
|
+
import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
|
|
36
|
+
import { renameWithRetry } from "../utils/renameWithRetry.js";
|
|
36
37
|
import { join } from "node:path";
|
|
37
38
|
import { FRAME_FILENAME_PREFIX, framePathsFromDirectory } from "./extractedFrameIndex.js";
|
|
38
39
|
/** Filename prefix for extracted frames. Shared with the extractor. */
|
|
@@ -172,7 +173,7 @@ function isTargetExistsRenameError(err) {
|
|
|
172
173
|
* winner is always correct. Returns null when no winner is present.
|
|
173
174
|
*/
|
|
174
175
|
function adoptPublishedWinner(entry, partialDir) {
|
|
175
|
-
if (!existsSync(join(entry.dir, COMPLETE_SENTINEL)))
|
|
176
|
+
if (!existsSync(join(entry.dir, COMPLETE_SENTINEL)) || !hasFrameFiles(entry.dir))
|
|
176
177
|
return null;
|
|
177
178
|
removeDir(partialDir);
|
|
178
179
|
return { dir: entry.dir, published: true };
|
|
@@ -185,7 +186,7 @@ export function publishCacheEntry(entry, partialDir) {
|
|
|
185
186
|
return { dir: partialDir, published: false };
|
|
186
187
|
}
|
|
187
188
|
try {
|
|
188
|
-
|
|
189
|
+
renameWithRetry(partialDir, entry.dir);
|
|
189
190
|
return { dir: entry.dir, published: true };
|
|
190
191
|
}
|
|
191
192
|
catch (err) {
|
|
@@ -202,7 +203,7 @@ export function publishCacheEntry(entry, partialDir) {
|
|
|
202
203
|
return { dir: partialDir, published: false };
|
|
203
204
|
}
|
|
204
205
|
try {
|
|
205
|
-
|
|
206
|
+
renameWithRetry(partialDir, entry.dir);
|
|
206
207
|
return { dir: entry.dir, published: true };
|
|
207
208
|
}
|
|
208
209
|
catch {
|
|
@@ -393,14 +393,17 @@ export async function driveWarmupTicks(options, state) {
|
|
|
393
393
|
if (!state.running)
|
|
394
394
|
return;
|
|
395
395
|
}
|
|
396
|
+
let ticked = false;
|
|
396
397
|
try {
|
|
397
398
|
await options.tick(state.ticks * options.intervalMs, options.intervalMs);
|
|
398
399
|
state.ticks += 1;
|
|
400
|
+
ticked = true;
|
|
399
401
|
}
|
|
400
402
|
catch {
|
|
401
403
|
// Page not ready yet; keep spinning.
|
|
402
404
|
}
|
|
403
|
-
|
|
405
|
+
if (!(options.lockWarmupTicks && !state.running && ticked))
|
|
406
|
+
await sleep(options.intervalMs);
|
|
404
407
|
}
|
|
405
408
|
}
|
|
406
409
|
export function resolveCaptureSessionOptions(options, browserVersion, platform = process.platform) {
|
|
@@ -46,6 +46,8 @@ export interface WorkerResult {
|
|
|
46
46
|
perf?: CapturePerfSummary;
|
|
47
47
|
error?: string;
|
|
48
48
|
diagnostics?: string[];
|
|
49
|
+
/** The worker session's `[Browser:PAGEERROR]` console lines, kept so the render's error count sees them. */
|
|
50
|
+
pageErrorLines?: string[];
|
|
49
51
|
failure?: CaptureFailure;
|
|
50
52
|
}
|
|
51
53
|
export interface ParallelProgress {
|