@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 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, renameSync, rmSync, writeFileSync, } from "fs";
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
- // `end` stays a plain numeric read (the mixer derives the real segment length
432
- // from data-duration / natural media downstream); guard NaN so a malformed
433
- // value never poisons the mix instead of falling back to 0.
434
- const parseEnd = (raw) => {
435
- return parseStrictFiniteTimingNumber(raw) ?? 0;
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: parseEnd(el.getAttribute("data-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 default channel conversion and the input seek; a URL probe
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(() => null);
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, warmUp > 0 ? `atrim=start=${warmUp},asetpts=PTS-STARTPTS` : timestampTrim);
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
- renameSync(summed.track.srcPath, outputPath);
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
- renameSync(encoderInput, config.keepEncoderInputAt);
1020
- return { ...sum, outputPath, durationMs: sum.durationMs + encoded.durationMs };
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) => inPrepareSlot(async () => {
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, renameSync, rmSync, writeFileSync } from "fs";
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
- renameSync(tempPath, wavPath);
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
- if (error instanceof CaptureFailure && !options.workerDiagnostics && !options.signal?.aborted) {
106
- return error;
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
- let kind;
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
- (error instanceof CaptureFailure ? error.workerDiagnostics : undefined),
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, renameSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
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
- renameSync(partialDir, entry.dir);
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
- renameSync(partialDir, entry.dir);
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
- await sleep(options.intervalMs);
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 {