@hyperframes/producer 0.8.181 → 0.8.184

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.js CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  extractMediaMetadata as extractMediaMetadata4,
29
29
  resolveConfig as resolveConfig2,
30
30
  closeCaptureSession as closeCaptureSession8,
31
+ createCaptureSession as createCaptureSession8,
32
+ initializeSession as initializeSession8,
31
33
  createVideoFrameInjector,
32
34
  getEncoderPreset as getEncoderPreset3,
33
35
  distributeFrames as distributeFrames2,
@@ -1402,7 +1404,7 @@ function pidScope() {
1402
1404
  return {
1403
1405
  host: hostname(),
1404
1406
  // Without /proc (macOS, Windows) the boot time, to the minute, stands in for the boot id.
1405
- boot: attempt(() => readFileSync2("/proc/sys/kernel/random/boot_id", "utf-8").trim()) ?? String(Math.round(Date.now() / 6e4 - uptime() / 60)),
1407
+ boot: attempt(() => readFileSync2("/proc/sys/kernel/random/boot_id", "utf-8").trim()) ?? attempt(() => String(Math.round(Date.now() / 6e4 - uptime() / 60))),
1406
1408
  pidns: attempt(() => readlinkSync("/proc/self/ns/pid"))
1407
1409
  };
1408
1410
  }
@@ -1420,9 +1422,14 @@ function alive(pid) {
1420
1422
  return error.code === "EPERM";
1421
1423
  }
1422
1424
  }
1423
- function inThisPidScope(owner) {
1425
+ function knownPidScope() {
1424
1426
  const scope = pidScope();
1425
- return owner.host === scope.host && owner.boot === scope.boot && owner.pidns === scope.pidns;
1427
+ const unknown = scope.boot === void 0 || process.platform === "linux" && scope.pidns === void 0;
1428
+ return unknown ? void 0 : scope;
1429
+ }
1430
+ function inThisPidScope(owner) {
1431
+ const scope = knownPidScope();
1432
+ return scope !== void 0 && owner.host === scope.host && owner.boot === scope.boot && owner.pidns === scope.pidns;
1426
1433
  }
1427
1434
  function ownerState(dir) {
1428
1435
  let owner;
@@ -1487,7 +1494,9 @@ async function defaultArtifactDurationProbe(path) {
1487
1494
  const meta = await extractMediaMetadata(path);
1488
1495
  return {
1489
1496
  durationSeconds: meta.durationSeconds,
1490
- frames: meta.frames
1497
+ frames: meta.frames,
1498
+ width: meta.width,
1499
+ height: meta.height
1491
1500
  };
1492
1501
  }
1493
1502
  function buildArtifactExpectation(input) {
@@ -1496,7 +1505,9 @@ function buildArtifactExpectation(input) {
1496
1505
  const expectation = {
1497
1506
  expectedDurationSeconds: input.durationSeconds,
1498
1507
  fps: input.fps,
1499
- expectedFrames: input.expectedFrames
1508
+ expectedFrames: input.expectedFrames,
1509
+ expectedWidth: input.width,
1510
+ expectedHeight: input.height
1500
1511
  };
1501
1512
  if (input.outputFormat !== "hls") return expectation;
1502
1513
  return {
@@ -1564,6 +1575,16 @@ function assertFrameCountWithinTolerance(expectedFrames, probedFrames, stagingPa
1564
1575
  `Render artifact is truncated: expected ${expectedFrames} frames, probed ${probedFrames} frames (shortfall ${shortfall} frames). Artifact: ${stagingPath}`
1565
1576
  );
1566
1577
  }
1578
+ function assertSizeMatches(expected, probed, stagingPath) {
1579
+ const { expectedWidth: width, expectedHeight: height } = expected;
1580
+ if (width === void 0 || height === void 0) return;
1581
+ if (probed.width === void 0 || probed.height === void 0) return;
1582
+ const fits = (want, got) => got === want || got === want + want % 2;
1583
+ if (fits(width, probed.width) && fits(height, probed.height)) return;
1584
+ throw new Error(
1585
+ `Render artifact has the wrong size: expected ${width}x${height}, probed ${probed.width}x${probed.height}. Artifact: ${stagingPath}`
1586
+ );
1587
+ }
1567
1588
  var ArtifactTransaction = class {
1568
1589
  constructor(destinationPath, kind, fileSystem = defaultFileSystem, durationProbe = defaultArtifactDurationProbe) {
1569
1590
  this.kind = kind;
@@ -1634,6 +1655,7 @@ var ArtifactTransaction = class {
1634
1655
  if (expected.expectedFrames !== void 0) {
1635
1656
  assertFrameCountWithinTolerance(expected.expectedFrames, probed.frames, probePath);
1636
1657
  }
1658
+ assertSizeMatches(expected, probed, probePath);
1637
1659
  }
1638
1660
  async runDurationProbe(probePath) {
1639
1661
  try {
@@ -3182,7 +3204,9 @@ function aggregateDrawElement(perfs, de) {
3182
3204
  if (perfs.length === 0) return void 0;
3183
3205
  const modes = [...new Set(perfs.map((p) => p.captureMode).filter(Boolean))].sort();
3184
3206
  const gateReasons = [
3185
- ...new Set(perfs.map((p) => p.deGateReason).filter((r) => !!r))
3207
+ ...new Set(
3208
+ [de.probeGateReason, ...perfs.map((p) => p.deGateReason)].filter((r) => !!r)
3209
+ )
3186
3210
  ].sort();
3187
3211
  const gpuRenderers = [
3188
3212
  ...new Set(perfs.map((p) => p.gpuRenderer).filter((r) => !!r))
@@ -8682,6 +8706,7 @@ async function runAudioStage(input) {
8682
8706
  audios,
8683
8707
  masterFxChain,
8684
8708
  urlByAbsentLocalPath,
8709
+ keepEncoderInputAt,
8685
8710
  abortSignal,
8686
8711
  assertNotAborted,
8687
8712
  log
@@ -8701,7 +8726,13 @@ async function runAudioStage(input) {
8701
8726
  audioOutputPath,
8702
8727
  duration,
8703
8728
  abortSignal,
8704
- { ffmpegProcessTimeout, audioGain, masterFxChain, urlByAbsentLocalPath },
8729
+ {
8730
+ ffmpegProcessTimeout,
8731
+ audioGain,
8732
+ masterFxChain,
8733
+ urlByAbsentLocalPath,
8734
+ keepEncoderInputAt
8735
+ },
8705
8736
  compiledDir
8706
8737
  );
8707
8738
  } catch (err) {
@@ -8815,13 +8846,29 @@ function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, free
8815
8846
  }
8816
8847
  return { available: false, estimatedBytes, freeBytes };
8817
8848
  }
8818
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis) {
8849
+ function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
8850
+ const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
8851
+ if (workerCount > 1 && oneWorker.enabled) {
8852
+ return `This render stores frames because it captures with ${workerCount} workers. Free up disk space, or re-run this render with --workers 1 to stream frames into the encoder instead. One worker is slower, so keep the default worker count for renders that fit on disk.`;
8853
+ }
8854
+ switch (oneWorker.reason) {
8855
+ case "format_excluded":
8856
+ return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
8857
+ case "disabled_by_config":
8858
+ return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
8859
+ case "duration_cap":
8860
+ return "The streaming duration cap (PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED=true) sends renders longer than PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS to disk at any worker count. Free up disk space, unset the cap, or use --low-memory-mode, which streams past it on one worker.";
8861
+ default:
8862
+ return "Free up disk space.";
8863
+ }
8864
+ }
8865
+ function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
8819
8866
  const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
8820
8867
  return new Error(
8821
- `Disk capture may need ~${(needBytes / 1e6).toFixed(1)} MB of temporary frame storage${basisNote}, but only ${(freeBytes / 1e6).toFixed(1)} MB is free at ${framesDir}. Disk capture stores every frame as an image file (JPEG, or PNG for alpha). mp4/mov renders stream instead: always at --workers 1, and multi-worker on Linux BeginFrame capture. This render landed on disk because the output is png-sequence or gif, because it is multi-worker screenshot capture (opt in with HF_CAPTURE_PARALLEL_STREAM=true), or because streaming was switched off: HF_CAPTURE_PARALLEL_STREAM=false, PRODUCER_ENABLE_STREAMING_ENCODE=false, or the duration cap (PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED=true) on a render longer than PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS. HDR and shader-transition renders never reach this check; they run their own compositor. Re-run with --workers 1, use --low-memory-mode, or free up disk space.`
8868
+ `Disk capture may need ~${(needBytes / 1e6).toFixed(1)} MB of temporary frame storage${basisNote}, but only ${(freeBytes / 1e6).toFixed(1)} MB is free at ${framesDir}. Disk capture stores every frame as an image file (JPEG, or PNG for alpha). ${remedy}`
8822
8869
  );
8823
8870
  }
8824
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes) {
8871
+ function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
8825
8872
  const headroom = inspectDiskCaptureHeadroom(
8826
8873
  framesDir,
8827
8874
  totalFrames,
@@ -8833,7 +8880,8 @@ function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeD
8833
8880
  headroom.estimatedBytes,
8834
8881
  headroom.freeBytes,
8835
8882
  framesDir,
8836
- "estimate"
8883
+ "estimate",
8884
+ remedy
8837
8885
  );
8838
8886
  }
8839
8887
  function measureCaptureFrameBytes(workDir, framesDir) {
@@ -8853,7 +8901,7 @@ function measureCaptureFrameBytes(workDir, framesDir) {
8853
8901
  return total;
8854
8902
  }
8855
8903
  function createDiskCaptureProjection(input) {
8856
- const { framesDir, totalFrames, measureBytes, freeDiskBytes = freeDiskBytesAt } = input;
8904
+ const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
8857
8905
  let checked = false;
8858
8906
  return (capturedFrames) => {
8859
8907
  if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
@@ -8865,7 +8913,7 @@ function createDiskCaptureProjection(input) {
8865
8913
  if (remaining <= 0 || freeBytes === null) return;
8866
8914
  const needBytes = Math.ceil(measured / capturedFrames * remaining);
8867
8915
  if (needBytes > freeBytes * 0.9) {
8868
- throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured");
8916
+ throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
8869
8917
  }
8870
8918
  };
8871
8919
  }
@@ -8907,11 +8955,13 @@ async function runCaptureStage(input) {
8907
8955
  }
8908
8956
  }
8909
8957
  const captureOptions = buildCaptureOptions();
8910
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions);
8958
+ const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
8959
+ assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
8911
8960
  const checkDiskProjection = createDiskCaptureProjection({
8912
8961
  framesDir,
8913
8962
  totalFrames,
8914
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir)
8963
+ measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
8964
+ remedy
8915
8965
  });
8916
8966
  if (workerCount > 1) {
8917
8967
  const attempts = await executeDiskCaptureWithAdaptiveRetry({
@@ -8932,6 +8982,7 @@ async function runCaptureStage(input) {
8932
8982
  if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
8933
8983
  return;
8934
8984
  }
8985
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
8935
8986
  job.framesRendered = progress.capturedFrames;
8936
8987
  checkDiskProjection(progress.capturedFrames);
8937
8988
  const frameProgress = progress.capturedFrames / progress.totalFrames;
@@ -10101,6 +10152,7 @@ import {
10101
10152
  statSync as statSync5,
10102
10153
  writeFileSync as writeFileSync4
10103
10154
  } from "node:fs";
10155
+ import { readFile as readFile2 } from "node:fs/promises";
10104
10156
  import { dirname as dirname8, join as join24 } from "node:path";
10105
10157
  import {
10106
10158
  encodeFramesChunkedConcat,
@@ -10108,7 +10160,8 @@ import {
10108
10160
  formatFfmpegError,
10109
10161
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10110
10162
  resolveConfig,
10111
- runFfmpeg as runFfmpeg3
10163
+ runFfmpeg as runFfmpeg3,
10164
+ spawnStreamingEncoder as spawnStreamingEncoder4
10112
10165
  } from "@hyperframes/engine";
10113
10166
  import {
10114
10167
  clearGifFramesBeforeNext,
@@ -10122,8 +10175,10 @@ function fpsToFfmpegArg2(fps) {
10122
10175
  }
10123
10176
  var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
10124
10177
  var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
10178
+ var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
10125
10179
  function framesInput(input, fpsArg) {
10126
10180
  return [
10181
+ ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
10127
10182
  "-framerate",
10128
10183
  fpsArg,
10129
10184
  ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
@@ -10243,6 +10298,124 @@ function startEncodeProgress(job, stage, onProgress) {
10243
10298
  });
10244
10299
  return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10245
10300
  }
10301
+ function resolveEngineConfig(input) {
10302
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10303
+ }
10304
+ function videoEncoderOptions(input, engineCfg) {
10305
+ const { job, preset } = input;
10306
+ return {
10307
+ fps: job.config.fps,
10308
+ width: input.width,
10309
+ height: input.height,
10310
+ codec: preset.codec,
10311
+ preset: preset.preset,
10312
+ quality: input.effectiveQuality,
10313
+ bitrate: input.effectiveBitrate,
10314
+ pixelFormat: preset.pixelFormat,
10315
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
10316
+ useGpu: job.config.useGpu,
10317
+ hdr: preset.hdr,
10318
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
10319
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
10320
+ // renders leave both undefined → preserves the existing open-GOP output.
10321
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10322
+ gopSize: input.gopSize
10323
+ };
10324
+ }
10325
+ function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10326
+ return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
10327
+ }
10328
+ async function readLandedFrame(workerPath, mergedPath) {
10329
+ if (workerPath !== void 0) {
10330
+ try {
10331
+ return await readFile2(workerPath);
10332
+ } catch (error) {
10333
+ if (error.code !== "ENOENT") throw error;
10334
+ }
10335
+ }
10336
+ return readFile2(mergedPath);
10337
+ }
10338
+ function encodeWhileCapturing(input, totalFrames) {
10339
+ const engineCfg = resolveEngineConfig(input);
10340
+ const stop = new AbortController();
10341
+ const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10342
+ const landed = /* @__PURE__ */ new Map();
10343
+ let captureDone = false;
10344
+ let framesFed = 0;
10345
+ let wake = null;
10346
+ const nudge = () => {
10347
+ wake?.();
10348
+ wake = null;
10349
+ };
10350
+ signal.addEventListener("abort", nudge, { once: true });
10351
+ let reportFed = null;
10352
+ const encoder = spawnStreamingEncoder4(
10353
+ input.videoOnlyPath,
10354
+ { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10355
+ signal,
10356
+ engineCfg
10357
+ );
10358
+ const fed = (async () => {
10359
+ const stream = await encoder;
10360
+ for (let next = 0; next < totalFrames; next++) {
10361
+ while (!landed.has(next) && !captureDone && !signal.aborted) {
10362
+ await new Promise((resolve12) => wake = resolve12);
10363
+ }
10364
+ if (signal.aborted) return;
10365
+ const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10366
+ const frame = await readLandedFrame(landed.get(next), mergedPath);
10367
+ landed.delete(next);
10368
+ if (!await stream.writeFrame(frame)) {
10369
+ throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10370
+ }
10371
+ framesFed++;
10372
+ reportFed?.(framesFed);
10373
+ }
10374
+ })().then(
10375
+ () => void 0,
10376
+ (error) => error ?? new Error("encode failed")
10377
+ );
10378
+ const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10379
+ return {
10380
+ onFrameLanded: (frame) => {
10381
+ landed.set(frame.index, frame.path);
10382
+ nudge();
10383
+ },
10384
+ finish: async () => {
10385
+ const start = Date.now();
10386
+ captureDone = true;
10387
+ input.log.info("[Render] Encoded frames while capturing", {
10388
+ framesFed,
10389
+ totalFrames
10390
+ });
10391
+ const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10392
+ reportFed = encoded;
10393
+ nudge();
10394
+ const failure = await fed;
10395
+ const result = await closeEncoder();
10396
+ input.assertNotAborted();
10397
+ if (failure === void 0 && result?.success) {
10398
+ encoded();
10399
+ return { encodeMs: Date.now() - start };
10400
+ }
10401
+ input.log.warn(
10402
+ "[Render] Encoding while capturing failed; encoding the frames after capture",
10403
+ {
10404
+ error: String(failure ?? result?.error)
10405
+ }
10406
+ );
10407
+ return runEncodeStage(input);
10408
+ },
10409
+ abort: async () => {
10410
+ stop.abort();
10411
+ await fed;
10412
+ await closeEncoder();
10413
+ },
10414
+ get framesFed() {
10415
+ return framesFed;
10416
+ }
10417
+ };
10418
+ }
10246
10419
  async function runEncodeStage(input) {
10247
10420
  const {
10248
10421
  job,
@@ -10250,16 +10423,11 @@ async function runEncodeStage(input) {
10250
10423
  outputPath,
10251
10424
  framesDir,
10252
10425
  videoOnlyPath,
10253
- width,
10254
- height,
10255
10426
  needsAlpha,
10256
10427
  hasAudio,
10257
10428
  audioOutputPath,
10258
10429
  isPngSequence,
10259
10430
  isGif,
10260
- preset,
10261
- effectiveQuality,
10262
- effectiveBitrate,
10263
10431
  enableChunkedEncode,
10264
10432
  chunkedEncodeSize,
10265
10433
  abortSignal,
@@ -10289,7 +10457,7 @@ async function runEncodeStage(input) {
10289
10457
  encoded();
10290
10458
  return { encodeMs: Date.now() - stage5Start };
10291
10459
  }
10292
- const engineCfg = input.engineConfig ?? job.config.producerConfig ?? resolveConfig();
10460
+ const engineCfg = resolveEngineConfig(input);
10293
10461
  if (isGif) {
10294
10462
  const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10295
10463
  if (hasAudio) {
@@ -10319,24 +10487,7 @@ async function runEncodeStage(input) {
10319
10487
  const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10320
10488
  const frameExt = frameFileExtension2(input.captureImageFormat);
10321
10489
  const framePattern = `frame_%06d.${frameExt}`;
10322
- const encoderOpts = {
10323
- fps: job.config.fps,
10324
- width,
10325
- height,
10326
- codec: preset.codec,
10327
- preset: preset.preset,
10328
- quality: effectiveQuality,
10329
- bitrate: effectiveBitrate,
10330
- pixelFormat: preset.pixelFormat,
10331
- vp9CpuUsed: engineCfg.vp9CpuUsed,
10332
- useGpu: job.config.useGpu,
10333
- hdr: preset.hdr,
10334
- // Distributed chunk renders pass these so the encoder writes closed-GOP
10335
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
10336
- // renders leave both undefined → preserves the existing open-GOP output.
10337
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10338
- gopSize: input.gopSize
10339
- };
10490
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
10340
10491
  const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10341
10492
  framesDir,
10342
10493
  framePattern,
@@ -10374,7 +10525,8 @@ import { dirname as dirname9, join as join25 } from "node:path";
10374
10525
  import {
10375
10526
  DEFAULT_CONFIG,
10376
10527
  describeFfmpegFailure as describeFfmpegFailure2,
10377
- extractAudioMetadata as extractAudioMetadata2,
10528
+ encodeAac,
10529
+ extractAudioMetadata as extractAudioMetadata3,
10378
10530
  getFfprobeBinary,
10379
10531
  ManagedChildProcess,
10380
10532
  runFfmpeg as runFfmpeg5,
@@ -10383,7 +10535,12 @@ import {
10383
10535
  import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
10384
10536
 
10385
10537
  // src/services/render/audioTruePeak.ts
10386
- import { aacSegmentCount, describeFfmpegFailure, runFfmpeg as runFfmpeg4 } from "@hyperframes/engine";
10538
+ import {
10539
+ aacSegmentCount,
10540
+ describeFfmpegFailure,
10541
+ extractAudioMetadata as extractAudioMetadata2,
10542
+ runFfmpeg as runFfmpeg4
10543
+ } from "@hyperframes/engine";
10387
10544
  var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
10388
10545
  function checkedDbfs(value) {
10389
10546
  if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
@@ -10421,12 +10578,13 @@ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
10421
10578
  };
10422
10579
  }
10423
10580
  async function measureTruePeakDbfs(audioPath, options) {
10424
- const { timeoutMs, signal, segments } = options;
10581
+ const { timeoutMs, signal } = options;
10425
10582
  const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
10583
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
10426
10584
  const block = 4800;
10427
- const count = segments?.sampleRate === 48e3 ? aacSegmentCount(segments.durationSeconds) : 1;
10428
- if (!segments || count === 1) return wholeFile();
10429
- const cut = (k) => k * Math.floor(segments.durationSeconds * 10 / count) * block;
10585
+ const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
10586
+ if (count === 1) return wholeFile();
10587
+ const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
10430
10588
  const peers = new AbortController();
10431
10589
  const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
10432
10590
  const scans = await Promise.all(
@@ -10455,24 +10613,8 @@ var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
10455
10613
  var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
10456
10614
  var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
10457
10615
  var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
10458
- function buildAacTruePeakCorrectionArgs(inputPath, outputPath, targetDurationSeconds, attenuationDb) {
10459
- return [
10460
- "-i",
10461
- inputPath,
10462
- "-map",
10463
- "0:a:0",
10464
- "-vn",
10465
- "-af",
10466
- `volume=${attenuationDb.toFixed(3)}dB`,
10467
- "-t",
10468
- formatSeconds(targetDurationSeconds),
10469
- "-c:a",
10470
- "aac",
10471
- "-b:a",
10472
- "192k",
10473
- "-y",
10474
- outputPath
10475
- ];
10616
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
10617
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
10476
10618
  }
10477
10619
  function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
10478
10620
  const delta = targetDurationSeconds - sourceDurationSeconds;
@@ -10487,53 +10629,13 @@ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, tar
10487
10629
  if (delta > 0) {
10488
10630
  return {
10489
10631
  operation: "pad",
10490
- steps: [
10491
- {
10492
- kind: "normalize",
10493
- args: [
10494
- "-i",
10495
- audioPath,
10496
- "-af",
10497
- `apad,atrim=0:${targetSec}`,
10498
- "-t",
10499
- targetSec,
10500
- "-c:a",
10501
- "aac",
10502
- "-b:a",
10503
- "192k",
10504
- "-y",
10505
- outputPath
10506
- ]
10507
- }
10508
- ],
10632
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
10509
10633
  cleanupPaths: []
10510
10634
  };
10511
10635
  }
10512
10636
  return {
10513
10637
  operation: "trim",
10514
- steps: [
10515
- {
10516
- kind: "trim",
10517
- args: [
10518
- "-i",
10519
- audioPath,
10520
- "-af",
10521
- `atrim=duration=${targetSec},asetpts=PTS-STARTPTS`,
10522
- // `atrim` limits decoded samples but does not cap muxer timestamps
10523
- // introduced by encoder delay/flush. The output duration contract
10524
- // is enforced at the container boundary as well; without `-t`, a
10525
- // long primed source can retain its original tail after re-encode.
10526
- "-t",
10527
- targetSec,
10528
- "-c:a",
10529
- "aac",
10530
- "-b:a",
10531
- "192k",
10532
- "-y",
10533
- outputPath
10534
- ]
10535
- }
10536
- ],
10638
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
10537
10639
  cleanupPaths: []
10538
10640
  };
10539
10641
  }
@@ -10550,7 +10652,8 @@ async function padOrTrimAudioToVideoFrameCount(input) {
10550
10652
  const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
10551
10653
  const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
10552
10654
  const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
10553
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
10655
+ const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
10656
+ const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
10554
10657
  const [videoResult, audioResult] = await Promise.allSettled([
10555
10658
  probeVideo(input.videoPath),
10556
10659
  probeAudio(input.audioPath, input.signal)
@@ -10597,7 +10700,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
10597
10700
  );
10598
10701
  try {
10599
10702
  for (const step of plan2.steps) {
10600
- const ffmpegResult = await runner(step.args);
10703
+ const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
10601
10704
  if (!ffmpegResult.success) {
10602
10705
  return {
10603
10706
  success: false,
@@ -10629,7 +10732,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
10629
10732
  targetDurationSeconds,
10630
10733
  signal: input.signal,
10631
10734
  probeTruePeak,
10632
- runner
10735
+ encode
10633
10736
  });
10634
10737
  if (!correction.success) {
10635
10738
  return {
@@ -10659,12 +10762,8 @@ async function constrainAacTruePeak(input) {
10659
10762
  audioPath: input.audioPath,
10660
10763
  targetDurationSeconds: input.targetDurationSeconds,
10661
10764
  signal: input.signal,
10662
- probeTruePeak: async (audioPath, signal) => {
10663
- const { sampleRate } = await extractAudioMetadata2(audioPath, { signal });
10664
- const segments = { durationSeconds: input.targetDurationSeconds, sampleRate };
10665
- return measureTruePeakDbfs(audioPath, { timeoutMs, signal, segments });
10666
- },
10667
- runner: (args) => defaultRunFfmpeg(args, timeoutMs, input.signal)
10765
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
10766
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
10668
10767
  });
10669
10768
  }
10670
10769
  async function enforceAacTruePeak(input) {
@@ -10691,13 +10790,11 @@ async function enforceAacTruePeak(input) {
10691
10790
  }
10692
10791
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
10693
10792
  attenuationDb += correctionTargetDbfs - truePeakDbfs;
10694
- const result = await input.runner(
10695
- buildAacTruePeakCorrectionArgs(
10696
- input.audioPath,
10697
- correctedPath,
10698
- input.targetDurationSeconds,
10699
- attenuationDb
10700
- )
10793
+ const result = await input.encode(
10794
+ input.audioPath,
10795
+ correctedPath,
10796
+ input.targetDurationSeconds,
10797
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
10701
10798
  );
10702
10799
  if (!result.success) {
10703
10800
  return {
@@ -10792,7 +10889,7 @@ function parseFrameRate(rate) {
10792
10889
  return { fpsNum, fpsDen };
10793
10890
  }
10794
10891
  async function defaultProbeAudioInfo(audioPath, signal) {
10795
- const metadata = await extractAudioMetadata2(audioPath, { signal });
10892
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
10796
10893
  return {
10797
10894
  durationSeconds: metadata.durationSeconds,
10798
10895
  sampleRate: metadata.sampleRate,
@@ -10801,7 +10898,18 @@ async function defaultProbeAudioInfo(audioPath, signal) {
10801
10898
  };
10802
10899
  }
10803
10900
  async function defaultRunFfmpeg(args, timeoutMs, signal) {
10804
- const result = await runFfmpeg5(args, { signal, timeout: timeoutMs });
10901
+ return toStepResult(await runFfmpeg5(args, { signal, timeout: timeoutMs }), timeoutMs);
10902
+ }
10903
+ function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
10904
+ return async (inputPath, outputPath, durationSeconds, filter) => {
10905
+ const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
10906
+ return toStepResult(
10907
+ await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
10908
+ timeoutMs
10909
+ );
10910
+ };
10911
+ }
10912
+ function toStepResult(result, timeoutMs) {
10805
10913
  if (result.success) return { success: true };
10806
10914
  return {
10807
10915
  success: false,
@@ -10989,7 +11097,7 @@ function assertOutputScaleSupported(deviceScaleFactor, route) {
10989
11097
  const reason = UNSUPPORTED_REASON2[route];
10990
11098
  if (reason === null) return;
10991
11099
  throw new Error(
10992
- `[Render] --resolution other than the composition's size is not supported on the "${route}" capture route: ${reason}. Render at the composition's size, or as mp4 with page-side compositing.`
11100
+ `[Render] --resolution other than the composition's size is not supported on the "${route}" capture route: ${reason}. Render at the composition's size; or, for HDR footage, add --sdr to render this size in standard colour; or, for shader transitions, render as mp4.`
10993
11101
  );
10994
11102
  }
10995
11103
 
@@ -11206,7 +11314,7 @@ function getNextRetryWorkerCount(currentWorkers) {
11206
11314
  return Math.max(1, Math.floor(currentWorkers / 2));
11207
11315
  }
11208
11316
  function resolveRenderWorkDirPrefix(outputPath, jobId, platform2 = process.platform, systemTempDir = tmpdir2()) {
11209
- if (platform2 === "win32") return join26(systemTempDir, "hf-render-");
11317
+ if (platform2 === "win32" || platform2 === "darwin") return join26(systemTempDir, "hf-render-");
11210
11318
  return join26(dirname10(outputPath), `work-${jobId}-`);
11211
11319
  }
11212
11320
  var MAX_TRANSIENT_CAPTURE_RETRIES = 1;
@@ -11700,6 +11808,9 @@ function resolveDeShortBand(args) {
11700
11808
  function shouldPreferSingleWorkerDrawElement(args) {
11701
11809
  return args.workerCount > 1 && typeof args.requestedWorkers !== "number" && args.useDrawElement && !args.deCompileGate && !args.forceScreenshot && outputUsesH264Pipeline(args.outputFormat) && args.minFrames > 0 && args.totalFrames >= args.minFrames && args.singleWorkerStreamingOk && !args.layeredOrEffectRoute && !args.supersampling && !args.probeDeGated && !args.experimentalParallelDeOptIn;
11702
11810
  }
11811
+ function isProbeDeGated(session) {
11812
+ return session !== null && session.captureMode !== "drawelement" && !session.deInitDeferred;
11813
+ }
11703
11814
  function resolveInversionRetryPlan(args) {
11704
11815
  if (args.deWorkerInversion !== "inverted") return null;
11705
11816
  const workerCount = args.isMemoryExhaustion ? 1 : args.preInversionWorkerCount;
@@ -11781,7 +11892,8 @@ async function closeOrphanedProbeForRetry(probe, closer, log, retryContext) {
11781
11892
  }
11782
11893
  function resolveParallelCaptureMode(args) {
11783
11894
  const supersampling = (args.deviceScaleFactor ?? 1) !== 1;
11784
- return args.headlessShell && args.platform === "linux" && !args.forceScreenshot && !supersampling ? "beginframe" : "screenshot";
11895
+ const headlessShell = args.headlessShellPath !== void 0;
11896
+ return headlessShell && args.platform === "linux" && !args.forceScreenshot && !supersampling ? "beginframe" : "screenshot";
11785
11897
  }
11786
11898
  function isCaptureParallelStreamRouterEnabled(env, resolvedMode) {
11787
11899
  const raw = env.HF_CAPTURE_PARALLEL_STREAM?.trim().toLowerCase();
@@ -12389,6 +12501,7 @@ async function executeRenderPipeline(input) {
12389
12501
  );
12390
12502
  const nativeHdrIds = /* @__PURE__ */ new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
12391
12503
  const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0);
12504
+ if (hasHdrContent) assertOutputScaleSupported(deviceScaleFactor, "hdr_layered");
12392
12505
  const deSingleMinFramesRaw = process.env.HF_DE_SINGLE_MIN_FRAMES;
12393
12506
  const deSingleMinFramesNum = deSingleMinFramesRaw === void 0 || deSingleMinFramesRaw.trim() === "" ? 900 : Number(deSingleMinFramesRaw);
12394
12507
  const deSingleMinFrames = Number.isFinite(deSingleMinFramesNum) ? deSingleMinFramesNum : 900;
@@ -12429,7 +12542,7 @@ async function executeRenderPipeline(input) {
12429
12542
  singleWorkerStreamingOk: shouldUseStreamingEncode(cfg, outputFormat, 1, job.duration),
12430
12543
  layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions,
12431
12544
  supersampling: deviceScaleFactor !== 1,
12432
- probeDeGated: probeSession !== null && probeSession.captureMode !== "drawelement" && !probeSession.deInitDeferred,
12545
+ probeDeGated: isProbeDeGated(probeSession),
12433
12546
  experimentalParallelDeOptIn: process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true" || // Verified parallel DE streaming (opt-in) wants its parallelism kept.
12434
12547
  process.env.HF_DE_PARALLEL_STREAM === "true"
12435
12548
  };
@@ -12438,14 +12551,14 @@ async function executeRenderPipeline(input) {
12438
12551
  ...deInversionArgs,
12439
12552
  minFrames: Math.min(deSingleMinFrames, deShortBandMinFrames)
12440
12553
  });
12441
- const deShortBand = resolveDeShortBand({
12554
+ let deShortBand = resolveDeShortBand({
12442
12555
  invertAtBaseFloor,
12443
12556
  invertAtBandFloor,
12444
12557
  bandEnabled: deShortBandEnabled,
12445
12558
  bandOpen: deShortBandOpen,
12446
12559
  elementCountSource: compositionElementCountSource
12447
12560
  });
12448
- const deInversionEligible = deShortBandRoute && deShortBand === "applied" ? invertAtBandFloor : invertAtBaseFloor;
12561
+ let deInversionEligible = deShortBandRoute && deShortBand === "applied" ? invertAtBandFloor : invertAtBaseFloor;
12449
12562
  const deInversionEffectiveMinFrames = deShortBandRoute && deShortBand === "applied" ? Math.min(deSingleMinFrames, deShortBandMinFrames) : deSingleMinFrames;
12450
12563
  const deParallelRouterEnabled = isDeParallelRouterEnabled(process.env);
12451
12564
  const deParallelMinFramesRaw = process.env.HF_DE_PARALLEL_MIN_FRAMES;
@@ -12454,7 +12567,7 @@ async function executeRenderPipeline(input) {
12454
12567
  const deParallelMinMemRaw = process.env.HF_DE_PARALLEL_MIN_MEM_MB;
12455
12568
  const deParallelMinMemNum = deParallelMinMemRaw === void 0 || deParallelMinMemRaw.trim() === "" ? 24576 : Number(deParallelMinMemRaw);
12456
12569
  const deParallelMinMemoryMb = Number.isFinite(deParallelMinMemNum) ? deParallelMinMemNum : 24576;
12457
- const deParallelRouterEligible = shouldPreferParallelDrawElement({
12570
+ let deParallelRouterEligible = shouldPreferParallelDrawElement({
12458
12571
  workerCount: WOULD_RESOLVE_MULTI_WORKER,
12459
12572
  requestedWorkers: job.config.workers,
12460
12573
  useDrawElement: cfg.useDrawElement,
@@ -12465,7 +12578,7 @@ async function executeRenderPipeline(input) {
12465
12578
  minFrames: deParallelMinFrames,
12466
12579
  layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions,
12467
12580
  supersampling: deviceScaleFactor !== 1,
12468
- probeDeGated: probeSession !== null && probeSession.captureMode !== "drawelement" && !probeSession.deInitDeferred,
12581
+ probeDeGated: isProbeDeGated(probeSession),
12469
12582
  experimentalParallelDeOptIn: process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true" || process.env.HF_DE_PARALLEL_STREAM === "true",
12470
12583
  routerEnabled: deParallelRouterEnabled,
12471
12584
  // Router pins 3 workers for the streaming path; don't pin when a
@@ -12481,6 +12594,35 @@ async function executeRenderPipeline(input) {
12481
12594
  totalMemoryMb: Math.round(totalmem2() / (1024 * 1024)),
12482
12595
  minMemoryMb: deParallelMinMemoryMb
12483
12596
  });
12597
+ if (probeSession === null && (deInversionEligible || deParallelRouterEligible)) {
12598
+ try {
12599
+ probeSession = await createCaptureSession8(
12600
+ activeFileServer.url,
12601
+ framesDir,
12602
+ buildCaptureOptions(),
12603
+ null,
12604
+ { ...cfg, forceScreenshot: captureForceScreenshot }
12605
+ );
12606
+ await initializeSession8(probeSession);
12607
+ } catch (error) {
12608
+ assertNotAborted();
12609
+ if (probeSession) {
12610
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
12611
+ await closeCaptureSession8(probeSession).catch(() => {
12612
+ });
12613
+ probeSession = null;
12614
+ }
12615
+ log.warn("[Render] Fast capture gate check failed; routing without it.", {
12616
+ error: error instanceof Error ? error.message : String(error)
12617
+ });
12618
+ }
12619
+ if (isProbeDeGated(probeSession)) {
12620
+ deInversionEligible = false;
12621
+ deParallelRouterEligible = false;
12622
+ deShortBand = void 0;
12623
+ }
12624
+ }
12625
+ const deProbeGateReason = isProbeDeGated(probeSession) ? probeSession.deGateReason : void 0;
12484
12626
  let workerCount;
12485
12627
  const captureStageObservationData = (extra = {}) => ({
12486
12628
  stagePhase: "capturing",
@@ -12668,7 +12810,7 @@ async function executeRenderPipeline(input) {
12668
12810
  }
12669
12811
  const parallelCaptureMode = resolveParallelCaptureMode({
12670
12812
  platform: process.platform,
12671
- headlessShell: resolveHeadlessShellPath(cfg) !== null,
12813
+ headlessShellPath: resolveHeadlessShellPath(cfg),
12672
12814
  forceScreenshot: captureForceScreenshot,
12673
12815
  deviceScaleFactor: captureOptions.deviceScaleFactor
12674
12816
  });
@@ -13270,6 +13412,34 @@ async function executeRenderPipeline(input) {
13270
13412
  throw new Error(`Disk capture requires sdr_disk plan; got ${capturePlan.kind}`);
13271
13413
  }
13272
13414
  resetCaptureAttemptProgress(job);
13415
+ const encodeInput = {
13416
+ job,
13417
+ log,
13418
+ outputPath: stagedOutputPath,
13419
+ framesDir,
13420
+ videoOnlyPath,
13421
+ width,
13422
+ height,
13423
+ needsAlpha,
13424
+ captureImageFormat: captureOptions.format ?? "jpeg",
13425
+ hasAudio,
13426
+ audioOutputPath,
13427
+ isPngSequence,
13428
+ isGif,
13429
+ preset,
13430
+ effectiveQuality,
13431
+ effectiveBitrate,
13432
+ enableChunkedEncode,
13433
+ chunkedEncodeSize,
13434
+ engineConfig: cfg,
13435
+ // Same value the streaming encoder above spreads — the disk and
13436
+ // chunked-concat encoders need an identical GOP lock for HLS.
13437
+ ...hlsEncoderGopLock,
13438
+ abortSignal: executionSignal,
13439
+ assertNotAborted,
13440
+ onProgress
13441
+ };
13442
+ const overlap = !cfg.useDrawElement && workerSizing !== void 0 && canEncodeWhileCapturing(encodeInput, capturePlan.workerCount, workerSizing.cpuCount) ? encodeWhileCapturing(encodeInput, totalFrames) : null;
13273
13443
  const captureFrameStart = Date.now();
13274
13444
  const invokeDiskCapture = (diskPlan) => observeRenderStage(
13275
13445
  observability,
@@ -13291,13 +13461,15 @@ async function executeRenderPipeline(input) {
13291
13461
  createRenderVideoFrameInjector,
13292
13462
  abortSignal: executionSignal,
13293
13463
  assertNotAborted,
13294
- onProgress
13464
+ onProgress,
13465
+ onFrameLanded: (frame) => overlap?.onFrameLanded(frame)
13295
13466
  })
13296
13467
  );
13297
13468
  let captureRes;
13298
13469
  try {
13299
13470
  captureRes = await invokeDiskCapture(capturePlan);
13300
13471
  } catch (err) {
13472
+ await overlap?.abort();
13301
13473
  if (!isDrawElementVerificationError(err) && !isDrawElementCaptureError(err) || err instanceof RenderCancelledError || executionSignal?.aborted === true) {
13302
13474
  throw err;
13303
13475
  }
@@ -13366,33 +13538,7 @@ async function executeRenderPipeline(input) {
13366
13538
  isGif,
13367
13539
  chunkedEncode: enableChunkedEncode
13368
13540
  }),
13369
- () => runEncodeStage({
13370
- job,
13371
- log,
13372
- outputPath: stagedOutputPath,
13373
- framesDir,
13374
- videoOnlyPath,
13375
- width,
13376
- height,
13377
- needsAlpha,
13378
- captureImageFormat: captureOptions.format ?? "jpeg",
13379
- hasAudio,
13380
- audioOutputPath,
13381
- isPngSequence,
13382
- isGif,
13383
- preset,
13384
- effectiveQuality,
13385
- effectiveBitrate,
13386
- enableChunkedEncode,
13387
- chunkedEncodeSize,
13388
- engineConfig: cfg,
13389
- // Same value the streaming encoder above spreads — the disk and
13390
- // chunked-concat encoders need an identical GOP lock for HLS.
13391
- ...hlsEncoderGopLock,
13392
- abortSignal: executionSignal,
13393
- assertNotAborted,
13394
- onProgress
13395
- })
13541
+ () => overlap ? overlap.finish() : runEncodeStage(encodeInput)
13396
13542
  );
13397
13543
  perfStages.encodeMs = encodeRes.encodeMs;
13398
13544
  }
@@ -13440,7 +13586,9 @@ async function executeRenderPipeline(input) {
13440
13586
  outputFormat,
13441
13587
  durationSeconds: job.duration,
13442
13588
  fps: fpsToNumber9(job.config.fps),
13443
- expectedFrames: captureTotalFrames
13589
+ expectedFrames: captureTotalFrames,
13590
+ width: outputWidth,
13591
+ height: outputHeight
13444
13592
  })
13445
13593
  );
13446
13594
  const totalElapsed = Date.now() - pipelineStart;
@@ -13476,6 +13624,7 @@ async function executeRenderPipeline(input) {
13476
13624
  drawElement: {
13477
13625
  compileGate: deCompileGate,
13478
13626
  clampReason: deClampReason,
13627
+ probeGateReason: deProbeGateReason,
13479
13628
  workerInversion: deWorkerInversion,
13480
13629
  preInversionWorkers: deWorkerInversion ? preRoutingWorkerCount : void 0,
13481
13630
  compositionElementCount,
@@ -13673,9 +13822,9 @@ import {
13673
13822
  import { join as join29 } from "node:path";
13674
13823
  import {
13675
13824
  closeCaptureSession as closeCaptureSession9,
13676
- createCaptureSession as createCaptureSession8,
13825
+ createCaptureSession as createCaptureSession9,
13677
13826
  createFrameLookupTable as createFrameLookupTable3,
13678
- initializeSession as initializeSession8
13827
+ initializeSession as initializeSession9
13679
13828
  } from "@hyperframes/engine";
13680
13829
 
13681
13830
  // src/services/distributed/chunkFrames.ts
@@ -14073,6 +14222,29 @@ function buildChunkCaptureOptions(input) {
14073
14222
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
14074
14223
  };
14075
14224
  }
14225
+ function shownDeferredFrameIndexes(planVideos, frames, fps) {
14226
+ const shown = /* @__PURE__ */ new Map();
14227
+ const deferred = planVideos.extracted.filter((video) => video.deferredRange);
14228
+ if (deferred.length === 0) return shown;
14229
+ const table = createFrameLookupTable2(
14230
+ planVideos.videos,
14231
+ deferred.map((video) => ({
14232
+ ...video,
14233
+ outputDir: "",
14234
+ framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)]))
14235
+ })),
14236
+ resolveRenderFpsConfig(fps).value
14237
+ );
14238
+ for (const frame of frames) {
14239
+ const globalTime = frame * fps.den / fps.num;
14240
+ for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
14241
+ const indexes = shown.get(videoId) ?? /* @__PURE__ */ new Set();
14242
+ indexes.add(payload.frameIndex);
14243
+ shown.set(videoId, indexes);
14244
+ }
14245
+ }
14246
+ return shown;
14247
+ }
14076
14248
  async function extractDeferredVideoFramesForChunk(input) {
14077
14249
  const deferred = input.planVideos.extracted.filter((video) => video.deferredRange);
14078
14250
  const remoteSources = input.remoteSources ?? {};
@@ -14089,24 +14261,7 @@ async function extractDeferredVideoFramesForChunk(input) {
14089
14261
  });
14090
14262
  }
14091
14263
  if (deferred.length === 0) return [];
14092
- const table = createFrameLookupTable2(
14093
- input.planVideos.videos,
14094
- deferred.map((video) => ({
14095
- ...video,
14096
- outputDir: input.outputRoot,
14097
- framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)]))
14098
- })),
14099
- resolveRenderFpsConfig(input.fps).value
14100
- );
14101
- const shown = /* @__PURE__ */ new Map();
14102
- for (const frame of input.frames) {
14103
- const globalTime = frame * input.fps.den / input.fps.num;
14104
- for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
14105
- const indexes = shown.get(videoId) ?? /* @__PURE__ */ new Set();
14106
- indexes.add(payload.frameIndex);
14107
- shown.set(videoId, indexes);
14108
- }
14109
- }
14264
+ const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
14110
14265
  return Promise.all(
14111
14266
  deferred.map(async (video) => {
14112
14267
  const outputDir = join28(input.outputRoot, video.videoId);
@@ -14132,7 +14287,8 @@ async function extractDeferredVideoFramesForChunk(input) {
14132
14287
  fps: input.fps,
14133
14288
  outputDir: input.outputRoot,
14134
14289
  format: range.format,
14135
- frameRanges: contiguousRuns(indexes)
14290
+ frameRanges: contiguousRuns(indexes),
14291
+ shownBox: input.shownBoxes?.get(video.videoId)
14136
14292
  },
14137
14293
  void 0,
14138
14294
  input.cfg
@@ -14179,7 +14335,7 @@ async function measurePlanSecondsPerFrame(input) {
14179
14335
  });
14180
14336
  let session = null;
14181
14337
  try {
14182
- session = await createCaptureSession8(
14338
+ session = await createCaptureSession9(
14183
14339
  fileServer.url,
14184
14340
  join29(workDir, "frame-cost"),
14185
14341
  buildChunkCaptureOptions({
@@ -14191,7 +14347,7 @@ async function measurePlanSecondsPerFrame(input) {
14191
14347
  createChunkVideoFrameInjectorFactory(lookup)(),
14192
14348
  createCaptureCalibrationConfig(input.cfg)
14193
14349
  );
14194
- await initializeSession8(session);
14350
+ await initializeSession9(session);
14195
14351
  const { estimate } = await measureCaptureCostFromSession(session, input.totalFrames, fps, log);
14196
14352
  return (estimate.p95Ms ?? 0) / 1e3;
14197
14353
  } finally {
@@ -14785,6 +14941,20 @@ function withDuration(buf, target, duration) {
14785
14941
  function timescaleOf(buf, header) {
14786
14942
  return buf.readUInt32BE(header.bodyStart + (buf[header.bodyStart] === 1 ? 20 : 12));
14787
14943
  }
14944
+ var TRACK_ID_AT = { tkhd: [12, 20], mvhd: [96, 108] };
14945
+ function idOf(buf, target) {
14946
+ return buf.readUInt32BE(
14947
+ target.bodyStart + TRACK_ID_AT[target.type][buf[target.bodyStart] === 1 ? 1 : 0]
14948
+ );
14949
+ }
14950
+ function withId(boxBytes, type, id) {
14951
+ const header = boxAt(boxBytes, 0, 0, boxBytes.length);
14952
+ boxBytes.writeUInt32BE(
14953
+ id,
14954
+ header.bodyStart + TRACK_ID_AT[type][boxBytes[header.bodyStart] === 1 ? 1 : 0]
14955
+ );
14956
+ return boxBytes;
14957
+ }
14788
14958
  function buildStbl(stsd, track) {
14789
14959
  const stts = runs(track.durations);
14790
14960
  const stsc = [];
@@ -14826,24 +14996,48 @@ function planTracksOf(tracks, plan2, index) {
14826
14996
  }
14827
14997
  return matched;
14828
14998
  }
14829
- function buildStitchedHeader(layouts) {
14830
- const plan2 = readTracks(layouts[0].moov);
14831
- const chunks = layouts.map((layout, i) => planTracksOf(readTracks(layout.moov), plan2, i));
14999
+ function sourceTracks(layouts, audio) {
15000
+ const chunkPlan = readTracks(layouts[0].moov);
15001
+ const audioTracks = audio ? readTracks(audio.moov) : [];
15002
+ if (audio && (audioTracks.length !== 1 || audioTracks[0].handler !== "soun")) {
15003
+ throw new Error("[mp4Stitch] the separate audio file must hold one audio track");
15004
+ }
15005
+ const plan2 = [...chunkPlan, ...audioTracks];
15006
+ const chunks = layouts.map((layout, i) => {
15007
+ const tracks = readTracks(layout.moov);
15008
+ if (audio && tracks.some((track) => track.handler === "soun")) {
15009
+ throw new Error(`[mp4Stitch] chunk ${i} carries its own audio`);
15010
+ }
15011
+ return planTracksOf(tracks, plan2, i);
15012
+ });
15013
+ return {
15014
+ plan: plan2,
15015
+ chunkTrackCount: chunkPlan.length,
15016
+ sources: audio ? [[...chunkPlan.map(() => null), audioTracks[0]], ...chunks] : chunks
15017
+ };
15018
+ }
15019
+ function buildStitchedHeader(layouts, audio) {
15020
+ const { plan: plan2, chunkTrackCount, sources } = sourceTracks(layouts, audio);
15021
+ const sourceLayouts = audio ? [audio, ...layouts] : layouts;
14832
15022
  const payloadBases = [];
14833
15023
  let payloadBytes = 0;
14834
- for (const layout of layouts) {
15024
+ for (const layout of sourceLayouts) {
14835
15025
  payloadBases.push(payloadBytes);
14836
15026
  payloadBytes += layout.payloadEnd - layout.payloadStart;
14837
15027
  }
14838
15028
  const template = layouts[0].moov;
14839
15029
  const root = boxAt(template, 0, 0, template.length);
14840
- const movieTimescale = timescaleOf(template, child(template, root, "mvhd"));
15030
+ const mvhd = child(template, root, "mvhd");
15031
+ const movieTimescale = timescaleOf(template, mvhd);
15032
+ const chunkTrackIds = plan2.slice(0, chunkTrackCount).map((track) => idOf(template, child(template, track.trak, "tkhd")));
15033
+ const audioTrackId = Math.max(...chunkTrackIds) + 1;
14841
15034
  const buildMoov = (dataStart2) => {
14842
15035
  const traks = plan2.map((track, t) => {
14843
- const parts = chunks.flatMap((tracks, chunk) => {
15036
+ const parts = sources.flatMap((tracks, chunk) => {
14844
15037
  const part = tracks[t];
14845
15038
  return part ? [{ ...part, chunk }] : [];
14846
15039
  });
15040
+ const moov = t < chunkTrackCount ? template : audio.moov;
14847
15041
  const merged = {
14848
15042
  durations: parts.flatMap((part) => part.durations),
14849
15043
  sizes: parts.flatMap((part) => part.sizes),
@@ -14852,9 +15046,9 @@ function buildStitchedHeader(layouts) {
14852
15046
  chunkOffsets: parts.flatMap(
14853
15047
  (part) => part.chunkOffsets.map((offset) => {
14854
15048
  const i = part.chunk;
14855
- const { payloadStart, payloadEnd } = layouts[i];
15049
+ const { payloadStart, payloadEnd } = sourceLayouts[i];
14856
15050
  if (offset < payloadStart || offset >= payloadEnd) {
14857
- throw new Error(`[mp4Stitch] chunk ${i} has samples outside its mdat`);
15051
+ throw new Error(`[mp4Stitch] source ${i} has samples outside its mdat`);
14858
15052
  }
14859
15053
  return offset - payloadStart + payloadBases[i] + dataStart2;
14860
15054
  })
@@ -14870,20 +15064,25 @@ function buildStitchedHeader(layouts) {
14870
15064
  elst.writeBigInt64BE(BigInt(track.mediaTime), 12);
14871
15065
  elst.writeUInt32BE(65536, 20);
14872
15066
  const edts = track.mediaTime ? box("edts", fullBox("elst", 1, elst)) : Buffer.alloc(0);
14873
- const trak = rebuild(template, track.trak, {
14874
- tkhd: (tkhd) => Buffer.concat([withDuration(template, tkhd, shown), edts]),
15067
+ const tkhdOf = (tkhd) => {
15068
+ const timed = withDuration(moov, tkhd, shown);
15069
+ return t < chunkTrackCount ? timed : withId(timed, "tkhd", audioTrackId);
15070
+ };
15071
+ const trak = rebuild(moov, track.trak, {
15072
+ tkhd: (tkhd) => Buffer.concat([tkhdOf(tkhd), edts]),
14875
15073
  edts: () => Buffer.alloc(0),
14876
- mdia: (mdia) => rebuild(template, mdia, {
14877
- mdhd: (mdhd) => withDuration(template, mdhd, mediaDuration),
14878
- minf: (minf) => rebuild(template, minf, { stbl: () => buildStbl(track.stsd, merged) })
15074
+ mdia: (mdia) => rebuild(moov, mdia, {
15075
+ mdhd: (mdhd) => withDuration(moov, mdhd, mediaDuration),
15076
+ minf: (minf) => rebuild(moov, minf, { stbl: () => buildStbl(track.stsd, merged) })
14879
15077
  })
14880
15078
  });
14881
15079
  return { trak, shown };
14882
15080
  });
14883
15081
  let next = 0;
15082
+ const movieHeader = withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown)));
14884
15083
  return rebuild(template, root, {
14885
- mvhd: (mvhd) => withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown))),
14886
- trak: () => traks[next++].trak
15084
+ mvhd: () => audio ? withId(movieHeader, "mvhd", audioTrackId + 1) : movieHeader,
15085
+ trak: () => ++next < chunkTrackCount ? traks[next - 1].trak : Buffer.concat(traks.slice(next - 1).map((t) => t.trak))
14887
15086
  });
14888
15087
  };
14889
15088
  const ftyp = layouts[0].ftyp;
@@ -14916,15 +15115,16 @@ async function copyPayload(path, layout, index, out) {
14916
15115
  );
14917
15116
  }
14918
15117
  }
14919
- async function writeStitchedMp4(chunkPaths, outputPath) {
14920
- const layouts = await Promise.all(chunkPaths.map((path) => readMp4Layout(fileRange(path))));
14921
- const header = buildStitchedHeader(layouts);
15118
+ async function writeStitchedMp4(chunkPaths, outputPath, audioPath) {
15119
+ const sourcePaths = audioPath ? [audioPath, ...chunkPaths] : chunkPaths;
15120
+ const layouts = await Promise.all(sourcePaths.map((path) => readMp4Layout(fileRange(path))));
15121
+ const header = audioPath ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
14922
15122
  mkdirSync11(dirname12(outputPath), { recursive: true });
14923
15123
  const workDir = mkdtempSync4(`${outputPath}.stitch-`);
14924
15124
  const out = createWriteStream(join31(workDir, "out.mp4"));
14925
15125
  try {
14926
15126
  out.write(header);
14927
- for (const [i, path] of chunkPaths.entries()) await copyPayload(path, layouts[i], i, out);
15127
+ for (const [i, path] of sourcePaths.entries()) await copyPayload(path, layouts[i], i, out);
14928
15128
  out.end();
14929
15129
  await finished(out);
14930
15130
  renameSync5(join31(workDir, "out.mp4"), outputPath);
@@ -15495,7 +15695,8 @@ function buildLockedRenderConfig(input) {
15495
15695
  chunkCount: input.chunkCount,
15496
15696
  runtimeEnv: input.runtimeEnv,
15497
15697
  variables: config.variables,
15498
- ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {}
15698
+ ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
15699
+ ...input.separateAudio ? { separateAudio: true } : {}
15499
15700
  };
15500
15701
  }
15501
15702
  function carriesChunkAudio(config, options) {
@@ -15590,8 +15791,10 @@ function createVideoSourceShipper(input) {
15590
15791
  return result;
15591
15792
  };
15592
15793
  }
15593
- async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
15594
- const planDir = executionPlanDir;
15794
+ function plansSeparateAudio(config, options) {
15795
+ return options.deferAudio === true && config.format === "mp4" && config.cfr !== true;
15796
+ }
15797
+ async function compilePlanComposition(projectDir, config, planDir, options) {
15595
15798
  rejectUnsupportedDistributedFormat(config);
15596
15799
  validateNoGpuEncode({
15597
15800
  useGpu: false,
@@ -15727,6 +15930,100 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
15727
15930
  });
15728
15931
  }
15729
15932
  }
15933
+ return {
15934
+ projectDir,
15935
+ log,
15936
+ sizeLimitBytes,
15937
+ abortSignal,
15938
+ assertNotAborted,
15939
+ cfg,
15940
+ job,
15941
+ workDir,
15942
+ compiledDir,
15943
+ finalCompiledDir,
15944
+ remoteFiles,
15945
+ urlByAbsentLocalPath,
15946
+ localPathByUrl,
15947
+ compileResult,
15948
+ compiled,
15949
+ composition,
15950
+ deviceScaleFactor,
15951
+ forceScreenshot,
15952
+ width,
15953
+ height,
15954
+ probeResult,
15955
+ totalFrames
15956
+ };
15957
+ }
15958
+ function mixPlanAudio(c, compiledDir, seconds, pcmPath) {
15959
+ return runAudioStage({
15960
+ projectDir: c.projectDir,
15961
+ workDir: c.workDir,
15962
+ compiledDir,
15963
+ urlByAbsentLocalPath: c.urlByAbsentLocalPath,
15964
+ duration: seconds,
15965
+ ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
15966
+ audioGain: c.cfg.audioGain,
15967
+ audios: c.composition.audios,
15968
+ masterFxChain: c.composition.masterFxChain,
15969
+ ...pcmPath ? { keepEncoderInputAt: pcmPath } : {},
15970
+ abortSignal: c.abortSignal,
15971
+ assertNotAborted: c.assertNotAborted
15972
+ });
15973
+ }
15974
+ async function extractPlanVideos(c, deferRangeExtraction) {
15975
+ const extractResult = await runExtractVideosStage({
15976
+ projectDir: c.projectDir,
15977
+ compiledDir: c.compiledDir,
15978
+ job: c.job,
15979
+ cfg: c.cfg,
15980
+ composition: c.composition,
15981
+ abortSignal: c.abortSignal,
15982
+ assertNotAborted: c.assertNotAborted,
15983
+ materializeSymlinks: true,
15984
+ deferRangeExtraction,
15985
+ urlByAbsentLocalPath: c.urlByAbsentLocalPath
15986
+ });
15987
+ if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
15988
+ if (extractResult.extractionResult) {
15989
+ assertVideoExtractionSucceeded(extractResult.extractionResult);
15990
+ }
15991
+ return extractResult;
15992
+ }
15993
+ async function holdPlanAudioPeak(c, audioPath, pcmPath, seconds) {
15994
+ const peak = await constrainAacTruePeak({
15995
+ audioPath,
15996
+ ...existsSync21(pcmPath) ? { sourcePath: pcmPath } : {},
15997
+ targetDurationSeconds: seconds,
15998
+ signal: c.abortSignal,
15999
+ timeoutMs: c.cfg.ffmpegProcessTimeout
16000
+ }).finally(() => rmSync11(pcmPath, { force: true }));
16001
+ if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
16002
+ }
16003
+ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
16004
+ const planDir = executionPlanDir;
16005
+ const c = await compilePlanComposition(projectDir, config, planDir, options);
16006
+ const {
16007
+ log,
16008
+ sizeLimitBytes,
16009
+ cfg,
16010
+ job,
16011
+ workDir,
16012
+ compiledDir,
16013
+ finalCompiledDir,
16014
+ remoteFiles,
16015
+ urlByAbsentLocalPath,
16016
+ localPathByUrl,
16017
+ compileResult,
16018
+ compiled,
16019
+ composition,
16020
+ deviceScaleFactor,
16021
+ forceScreenshot,
16022
+ width,
16023
+ height,
16024
+ probeResult,
16025
+ totalFrames
16026
+ } = c;
15730
16027
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
15731
16028
  let maxParallel = resolveMaxParallelChunks(config, totalFrames, megapixels, log);
15732
16029
  const measureFrameCost = chunkSizingUsesFrameCost(config) && planNeedsFrameCostMeasurement(compiled.html);
@@ -15737,24 +16034,9 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
15737
16034
  observedAt: "pre-extract",
15738
16035
  log
15739
16036
  });
15740
- stageStarted = Date.now();
15741
- const extractResult = await runExtractVideosStage({
15742
- projectDir,
15743
- compiledDir,
15744
- job,
15745
- cfg,
15746
- composition,
15747
- abortSignal,
15748
- assertNotAborted,
15749
- materializeSymlinks: true,
15750
- deferRangeExtraction: options.deferVideoExtraction,
15751
- urlByAbsentLocalPath
15752
- });
16037
+ let stageStarted = Date.now();
16038
+ const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
15753
16039
  logPlanStage(log, "extract", stageStarted);
15754
- if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
15755
- if (extractResult.extractionResult) {
15756
- assertVideoExtractionSucceeded(extractResult.extractionResult);
15757
- }
15758
16040
  const stagedVideoFrames = join34(compiledDir, "__hyperframes_video_frames");
15759
16041
  const videoFramesDst = join34(planDir, "video-frames");
15760
16042
  if (existsSync21(videoFramesDst)) rmSync11(videoFramesDst, { recursive: true, force: true });
@@ -15810,23 +16092,18 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
15810
16092
  "utf-8"
15811
16093
  );
15812
16094
  const digests = options.digests ?? /* @__PURE__ */ new Map();
15813
- const chunksCarryAudio2 = carriesChunkAudio(config, options);
15814
- const audioSeconds = chunksCarryAudio2 ? totalFrames / config.fps : probeResult.duration;
16095
+ const separateAudio = plansSeparateAudio(config, options);
16096
+ const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options);
16097
+ const audioSeconds = chunksCarryAudio ? totalFrames / config.fps : probeResult.duration;
16098
+ const planMixPcmPath = join34(workDir, "plan-mix.wav");
15815
16099
  stageStarted = Date.now();
15816
16100
  const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
15817
- runAudioStage({
15818
- projectDir,
15819
- workDir,
15820
- compiledDir: finalCompiledDir,
15821
- urlByAbsentLocalPath,
15822
- duration: audioSeconds,
15823
- ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
15824
- audioGain: cfg.audioGain,
15825
- audios: composition.audios,
15826
- masterFxChain: composition.masterFxChain,
15827
- abortSignal,
15828
- assertNotAborted
15829
- }).finally(() => logPlanStage(log, "audio", stageStarted)),
16101
+ separateAudio ? null : mixPlanAudio(
16102
+ c,
16103
+ finalCompiledDir,
16104
+ audioSeconds,
16105
+ chunksCarryAudio ? planMixPcmPath : void 0
16106
+ ).finally(() => logPlanStage(log, "audio", stageStarted)),
15830
16107
  (async () => {
15831
16108
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
15832
16109
  await prehashPlanFiles(dir, digests);
@@ -15876,22 +16153,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
15876
16153
  );
15877
16154
  const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
15878
16155
  const audioResult = audioOutcome.value;
15879
- if (audioResult.audioError) {
16156
+ if (audioResult?.audioError) {
15880
16157
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
15881
16158
  }
15882
16159
  const planAudioPath = join34(planDir, PLAN_AUDIO_RELATIVE_PATH);
15883
- if (audioResult.hasAudio && existsSync21(audioResult.audioOutputPath)) {
16160
+ if (audioResult?.hasAudio && existsSync21(audioResult.audioOutputPath)) {
15884
16161
  renameSync6(audioResult.audioOutputPath, planAudioPath);
15885
16162
  }
15886
- if (chunksCarryAudio2 && existsSync21(planAudioPath)) {
16163
+ if (chunksCarryAudio && existsSync21(planAudioPath)) {
15887
16164
  stageStarted = Date.now();
15888
- const peak = await constrainAacTruePeak({
15889
- audioPath: planAudioPath,
15890
- targetDurationSeconds: audioSeconds,
15891
- signal: abortSignal,
15892
- timeoutMs: cfg.ffmpegProcessTimeout
15893
- });
15894
- if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
16165
+ await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
15895
16166
  logPlanStage(log, "audio-peak", stageStarted);
15896
16167
  if (options.sliceChunkAudio) {
15897
16168
  stageStarted = Date.now();
@@ -15917,7 +16188,8 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
15917
16188
  effectiveChunkSize,
15918
16189
  chunkCount,
15919
16190
  runtimeEnv,
15920
- chunksCarryAudio: chunksCarryAudio2
16191
+ chunksCarryAudio,
16192
+ separateAudio
15921
16193
  });
15922
16194
  const compositionJson = {
15923
16195
  durationSeconds: job.duration ?? 0,
@@ -15962,7 +16234,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
15962
16234
  fontSnapshotSha,
15963
16235
  durationSeconds: job.duration ?? 0,
15964
16236
  totalFrames,
15965
- hasAudio: audioResult.hasAudio,
16237
+ hasAudio: audioResult?.hasAudio ?? false,
15966
16238
  digests
15967
16239
  });
15968
16240
  logPlanStage(log, "freeze", stageStarted);
@@ -16478,8 +16750,8 @@ function renderRequestFromDistributedConfig(input) {
16478
16750
 
16479
16751
  // src/services/frameCapture.ts
16480
16752
  import {
16481
- createCaptureSession as createCaptureSession9,
16482
- initializeSession as initializeSession9,
16753
+ createCaptureSession as createCaptureSession10,
16754
+ initializeSession as initializeSession10,
16483
16755
  closeCaptureSession as closeCaptureSession10,
16484
16756
  captureFrame as captureFrame2,
16485
16757
  captureFrameToBuffer as captureFrameToBuffer4,
@@ -17980,7 +18252,8 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
17980
18252
  )
17981
18253
  ),
17982
18254
  chunksCarryAudio: options.chunksCarryAudio,
17983
- sliceChunkAudio: true
18255
+ sliceChunkAudio: true,
18256
+ deferAudio: options.deferAudio
17984
18257
  });
17985
18258
  return await publishExecutionPlan(stagingRoot, publisher, { digests, log: config.logger });
17986
18259
  } catch (error) {
@@ -18316,7 +18589,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
18316
18589
  return mergePngFrameDirs(chunkPaths, outputPath, plan2.totalFrames, audioPath, start);
18317
18590
  }
18318
18591
  if (chunksAreStitchedMp4(planDir, cfr)) {
18319
- await writeStitchedMp4(chunkPaths, outputPath);
18592
+ const separateAudio = stitchesSeparateAudio(planDir) && audioPath !== null;
18593
+ await writeStitchedMp4(chunkPaths, outputPath, separateAudio ? audioPath : void 0);
18320
18594
  return {
18321
18595
  outputPath,
18322
18596
  durationMs: Date.now() - start,
@@ -18514,16 +18788,20 @@ async function runAssembleFfmpeg(args, step, signal, timeout) {
18514
18788
  }
18515
18789
  }
18516
18790
  function chunksAreStitchedMp4(planDir, cfr) {
18517
- if (!chunksCarryAudio(planDir)) return false;
18791
+ const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
18792
+ if (!chunksCarryAudio && !separateAudio) return false;
18518
18793
  if (cfr) {
18519
- throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks carry audio");
18794
+ throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks are stitched");
18520
18795
  }
18521
18796
  return true;
18522
18797
  }
18523
- function chunksCarryAudio(planDir) {
18798
+ function stitchesSeparateAudio(planDir) {
18799
+ return lockedAudio(planDir).separateAudio === true;
18800
+ }
18801
+ function lockedAudio(planDir) {
18524
18802
  const encoderJsonPath = join41(planDir, "meta", "encoder.json");
18525
- if (!existsSync27(encoderJsonPath)) return false;
18526
- return JSON.parse(readFileSync14(encoderJsonPath, "utf-8")).chunksCarryAudio === true;
18803
+ if (!existsSync27(encoderJsonPath)) return {};
18804
+ return JSON.parse(readFileSync14(encoderJsonPath, "utf-8"));
18527
18805
  }
18528
18806
  function readCfrEncoder(planDir, format) {
18529
18807
  if (format !== "mp4") {
@@ -18597,11 +18875,12 @@ import {
18597
18875
  calculateOptimalWorkers as calculateOptimalWorkers2,
18598
18876
  classifyCaptureFailure as classifyCaptureFailure4,
18599
18877
  closeCaptureSession as closeCaptureSession11,
18600
- createCaptureSession as createCaptureSession10,
18878
+ createCaptureSession as createCaptureSession11,
18601
18879
  createFrameLookupTable as createFrameLookupTable5,
18602
18880
  deriveBeginFrameProbeTimeTicks as deriveBeginFrameProbeTimeTicks2,
18603
18881
  getEncoderPreset as getEncoderPreset6,
18604
- initializeSession as initializeSession10,
18882
+ initializeSession as initializeSession11,
18883
+ measureVideoShownBoxes,
18605
18884
  probeBeginFrameLiveness as probeBeginFrameLiveness2,
18606
18885
  readWebGlVendorInfoFromCanvas,
18607
18886
  resolveConfig as resolveConfig7
@@ -18618,6 +18897,12 @@ function validatePlanVideosForChunk(value) {
18618
18897
  );
18619
18898
  }
18620
18899
  }
18900
+ var MIN_FRAMES_PER_CHUNK_BROWSER = 20;
18901
+ function resolveChunkCaptureWorkers(framesInChunk, machineSized, maxCaptureWorkers) {
18902
+ if (maxCaptureWorkers === void 0) return machineSized;
18903
+ const byFrames = Math.floor(framesInChunk / MIN_FRAMES_PER_CHUNK_BROWSER);
18904
+ return Math.max(machineSized, Math.floor(Math.min(maxCaptureWorkers, byFrames)));
18905
+ }
18621
18906
  function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
18622
18907
  if (frameRange === void 0) return { startFrame: slice.startFrame, endFrame: slice.endFrame };
18623
18908
  const { startFrame, endFrame } = frameRange;
@@ -18630,9 +18915,9 @@ function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
18630
18915
  return { startFrame, endFrame };
18631
18916
  }
18632
18917
  var distributedCaptureSessionDependencies = {
18633
- createCaptureSession: createCaptureSession10,
18918
+ createCaptureSession: createCaptureSession11,
18634
18919
  assertSwiftShader,
18635
- initializeSession: initializeSession10,
18920
+ initializeSession: initializeSession11,
18636
18921
  closeCaptureSession: closeCaptureSession11,
18637
18922
  readWebGlVendorInfo: readWebGlVendorInfoFromCanvas
18638
18923
  };
@@ -18823,34 +19108,15 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
18823
19108
  const framesDir = join42(workDir, "captured-frames");
18824
19109
  mkdirSync18(framesDir, { recursive: true });
18825
19110
  const videoFramesDir = `${workDir}.video-frames`;
18826
- const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
18827
- planDir,
18828
- planVideos,
18829
- frames: Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i),
18830
- fps: { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen },
18831
- outputRoot: videoFramesDir,
18832
- cfg,
18833
- remoteSources
18834
- }) : [];
19111
+ const chunkFrameNumbers = Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i);
19112
+ const chunkFps = { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen };
19113
+ const deferredVideos = planVideos?.extracted.filter((video) => video.deferredRange) ?? [];
19114
+ const shownFrames = planVideos ? shownDeferredFrameIndexes(planVideos, chunkFrameNumbers, chunkFps) : /* @__PURE__ */ new Map();
18835
19115
  const remoteVideoIds = new Set(
18836
19116
  planVideos?.extracted.filter(
18837
19117
  (video) => video.deferredRange && Object.hasOwn(remoteSources, video.deferredRange.sourcePath)
18838
19118
  ).map((video) => video.videoId)
18839
19119
  );
18840
- const remoteVideos = deferredVideoFrames.filter((video) => remoteVideoIds.has(video.videoId));
18841
- const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
18842
- planVideos.videos,
18843
- [
18844
- ...rebuildExtractedFramesFromPlanDir(
18845
- planDir,
18846
- planVideos.extracted.filter((video) => !video.deferredRange),
18847
- v2Manifest === null ? "dense-v1" : "sparse-v2"
18848
- ),
18849
- ...deferredVideoFrames
18850
- ],
18851
- resolveRenderFpsConfig(job.config.fps).value
18852
- ) : null;
18853
- const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
18854
19120
  const fileServer = await createFileServer({
18855
19121
  projectDir: compiledDir,
18856
19122
  compiledDir,
@@ -18866,10 +19132,18 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
18866
19132
  videoCount: planVideos?.videos.length ?? 0,
18867
19133
  html: readFileSync15(join42(compiledDir, "index.html"), "utf-8"),
18868
19134
  // The plan ships a deferred source only to chunks that show it; don't wait on the rest.
18869
- skipReadinessVideoIds: deferredVideoFrames.filter((video) => video.framePaths.size === 0 || remoteVideoIds.has(video.videoId)).map((video) => video.videoId),
18870
- videoMetadataHints: collectVideoMetadataHints(remoteVideos)
19135
+ skipReadinessVideoIds: deferredVideos.filter(
19136
+ (video) => !shownFrames.get(video.videoId)?.size || remoteVideoIds.has(video.videoId)
19137
+ ).map((video) => video.videoId),
19138
+ videoMetadataHints: collectVideoMetadataHints(
19139
+ deferredVideos.filter((video) => remoteVideoIds.has(video.videoId))
19140
+ )
18871
19141
  });
18872
- const chunkWorkerCount = calculateOptimalWorkers2(framesInChunk, void 0, cfg);
19142
+ const chunkWorkerCount = resolveChunkCaptureWorkers(
19143
+ framesInChunk,
19144
+ calculateOptimalWorkers2(framesInChunk, void 0, cfg),
19145
+ options?.maxCaptureWorkers
19146
+ );
18873
19147
  let session = null;
18874
19148
  let outputKind;
18875
19149
  let framesEncoded = 0;
@@ -18880,6 +19154,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
18880
19154
  const capturePerfs = [];
18881
19155
  const captureAttempts = [];
18882
19156
  let forceScreenshotForChunk = encoder.forceScreenshot;
19157
+ let shownBoxes;
18883
19158
  try {
18884
19159
  if (chunkWorkerCount === 1 || !forceScreenshotForChunk) {
18885
19160
  const bootStarted = Date.now();
@@ -18890,6 +19165,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
18890
19165
  cfg
18891
19166
  );
18892
19167
  sessionBootMs = Date.now() - bootStarted;
19168
+ shownBoxes = await measureVideoShownBoxes(
19169
+ session.page,
19170
+ [...shownFrames.keys()],
19171
+ chunkFrameNumbers.map((frame) => frame * chunkFps.den / chunkFps.num)
19172
+ );
18893
19173
  const browserSelectedScreenshot = session.launchCaptureMode === "screenshot";
18894
19174
  const beginFrameStalled = !browserSelectedScreenshot && await beginFrameSessionNeedsScreenshotFallback(session);
18895
19175
  if (browserSelectedScreenshot) {
@@ -18931,6 +19211,29 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
18931
19211
  session = null;
18932
19212
  }
18933
19213
  }
19214
+ const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
19215
+ planDir,
19216
+ planVideos,
19217
+ frames: chunkFrameNumbers,
19218
+ fps: chunkFps,
19219
+ outputRoot: videoFramesDir,
19220
+ cfg,
19221
+ remoteSources,
19222
+ shownBoxes
19223
+ }) : [];
19224
+ const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
19225
+ planVideos.videos,
19226
+ [
19227
+ ...rebuildExtractedFramesFromPlanDir(
19228
+ planDir,
19229
+ planVideos.extracted.filter((video) => !video.deferredRange),
19230
+ v2Manifest === null ? "dense-v1" : "sparse-v2"
19231
+ ),
19232
+ ...deferredVideoFrames
19233
+ ],
19234
+ resolveRenderFpsConfig(job.config.fps).value
19235
+ ) : null;
19236
+ const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
18934
19237
  const captureStarted = Date.now();
18935
19238
  captureMode = await runCaptureWithScreenshotFallback({
18936
19239
  forceScreenshot: forceScreenshotForChunk,
@@ -19121,6 +19424,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19121
19424
  encodeStageMs,
19122
19425
  workers: chunkWorkerCount,
19123
19426
  captureMode,
19427
+ // Device-pixel box each video's frames were extracted to cover; a video not listed kept full size.
19428
+ videoShownBoxes: Object.fromEntries(shownBoxes ?? []),
19124
19429
  sha256,
19125
19430
  outputKind,
19126
19431
  producerVersion: plan2.producerVersion,
@@ -19188,6 +19493,9 @@ async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
19188
19493
  rmSync17(workRoot, { recursive: true, force: true });
19189
19494
  }
19190
19495
  }
19496
+
19497
+ // src/services/distributed/projectHash.ts
19498
+ var READ_CHUNK_BYTES = 256 * 1024;
19191
19499
  export {
19192
19500
  CURRENT_PLAN_PROTOCOL,
19193
19501
  DEFAULT_CONFIG2 as DEFAULT_CONFIG,
@@ -19220,7 +19528,7 @@ export {
19220
19528
  captureFrameToBuffer4 as captureFrameToBuffer,
19221
19529
  closeCaptureSession10 as closeCaptureSession,
19222
19530
  collectRenderMedia,
19223
- createCaptureSession9 as createCaptureSession,
19531
+ createCaptureSession10 as createCaptureSession,
19224
19532
  createConsoleLogger,
19225
19533
  createFileServer,
19226
19534
  createOwnedRenderDir,
@@ -19238,7 +19546,7 @@ export {
19238
19546
  getCompositionDuration2 as getCompositionDuration,
19239
19547
  getDistributedRenderCapabilities,
19240
19548
  getPlanV2ExecutionPlanHash,
19241
- initializeSession9 as initializeSession,
19549
+ initializeSession10 as initializeSession,
19242
19550
  injectDeterministicFontFaces2 as injectDeterministicFontFaces,
19243
19551
  isTransientBrowserError3 as isTransientBrowserError,
19244
19552
  listAbandonedRenderDirs,
@@ -19252,6 +19560,7 @@ export {
19252
19560
  plan,
19253
19561
  planV2,
19254
19562
  planV2WithPublisher,
19563
+ prepareAnimatedGifInputs,
19255
19564
  prepareCaptureSessionForReuse4 as prepareCaptureSessionForReuse,
19256
19565
  prepareHyperframeLintBody,
19257
19566
  publishPlanV2FromExecutionPlan,