@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.
@@ -89,6 +89,8 @@ import {
89
89
  extractMediaMetadata as extractMediaMetadata4,
90
90
  resolveConfig as resolveConfig2,
91
91
  closeCaptureSession as closeCaptureSession8,
92
+ createCaptureSession as createCaptureSession8,
93
+ initializeSession as initializeSession8,
92
94
  createVideoFrameInjector,
93
95
  getEncoderPreset as getEncoderPreset3,
94
96
  distributeFrames as distributeFrames2,
@@ -4955,6 +4957,7 @@ async function runAudioStage(input) {
4955
4957
  audios,
4956
4958
  masterFxChain,
4957
4959
  urlByAbsentLocalPath,
4960
+ keepEncoderInputAt,
4958
4961
  abortSignal,
4959
4962
  assertNotAborted,
4960
4963
  log
@@ -4974,7 +4977,13 @@ async function runAudioStage(input) {
4974
4977
  audioOutputPath,
4975
4978
  duration,
4976
4979
  abortSignal,
4977
- { ffmpegProcessTimeout, audioGain, masterFxChain, urlByAbsentLocalPath },
4980
+ {
4981
+ ffmpegProcessTimeout,
4982
+ audioGain,
4983
+ masterFxChain,
4984
+ urlByAbsentLocalPath,
4985
+ keepEncoderInputAt
4986
+ },
4978
4987
  compiledDir
4979
4988
  );
4980
4989
  } catch (err) {
@@ -5088,13 +5097,29 @@ function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, free
5088
5097
  }
5089
5098
  return { available: false, estimatedBytes, freeBytes };
5090
5099
  }
5091
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis) {
5100
+ function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
5101
+ const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
5102
+ if (workerCount > 1 && oneWorker.enabled) {
5103
+ 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.`;
5104
+ }
5105
+ switch (oneWorker.reason) {
5106
+ case "format_excluded":
5107
+ return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
5108
+ case "disabled_by_config":
5109
+ return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
5110
+ case "duration_cap":
5111
+ 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.";
5112
+ default:
5113
+ return "Free up disk space.";
5114
+ }
5115
+ }
5116
+ function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
5092
5117
  const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
5093
5118
  return new Error(
5094
- `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.`
5119
+ `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}`
5095
5120
  );
5096
5121
  }
5097
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes) {
5122
+ function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
5098
5123
  const headroom = inspectDiskCaptureHeadroom(
5099
5124
  framesDir,
5100
5125
  totalFrames,
@@ -5106,7 +5131,8 @@ function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeD
5106
5131
  headroom.estimatedBytes,
5107
5132
  headroom.freeBytes,
5108
5133
  framesDir,
5109
- "estimate"
5134
+ "estimate",
5135
+ remedy
5110
5136
  );
5111
5137
  }
5112
5138
  function measureCaptureFrameBytes(workDir, framesDir) {
@@ -5126,7 +5152,7 @@ function measureCaptureFrameBytes(workDir, framesDir) {
5126
5152
  return total;
5127
5153
  }
5128
5154
  function createDiskCaptureProjection(input) {
5129
- const { framesDir, totalFrames, measureBytes, freeDiskBytes = freeDiskBytesAt } = input;
5155
+ const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
5130
5156
  let checked = false;
5131
5157
  return (capturedFrames) => {
5132
5158
  if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
@@ -5138,7 +5164,7 @@ function createDiskCaptureProjection(input) {
5138
5164
  if (remaining <= 0 || freeBytes === null) return;
5139
5165
  const needBytes = Math.ceil(measured / capturedFrames * remaining);
5140
5166
  if (needBytes > freeBytes * 0.9) {
5141
- throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured");
5167
+ throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
5142
5168
  }
5143
5169
  };
5144
5170
  }
@@ -5180,11 +5206,13 @@ async function runCaptureStage(input) {
5180
5206
  }
5181
5207
  }
5182
5208
  const captureOptions = buildCaptureOptions();
5183
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions);
5209
+ const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
5210
+ assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
5184
5211
  const checkDiskProjection = createDiskCaptureProjection({
5185
5212
  framesDir,
5186
5213
  totalFrames,
5187
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir)
5214
+ measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
5215
+ remedy
5188
5216
  });
5189
5217
  if (workerCount > 1) {
5190
5218
  const attempts = await executeDiskCaptureWithAdaptiveRetry({
@@ -5205,6 +5233,7 @@ async function runCaptureStage(input) {
5205
5233
  if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
5206
5234
  return;
5207
5235
  }
5236
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
5208
5237
  job.framesRendered = progress.capturedFrames;
5209
5238
  checkDiskProjection(progress.capturedFrames);
5210
5239
  const frameProgress = progress.capturedFrames / progress.totalFrames;
@@ -5445,7 +5474,8 @@ import {
5445
5474
  formatFfmpegError,
5446
5475
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
5447
5476
  resolveConfig,
5448
- runFfmpeg as runFfmpeg3
5477
+ runFfmpeg as runFfmpeg3,
5478
+ spawnStreamingEncoder as spawnStreamingEncoder4
5449
5479
  } from "@hyperframes/engine";
5450
5480
  import {
5451
5481
  clearGifFramesBeforeNext,
@@ -5459,8 +5489,10 @@ function fpsToFfmpegArg2(fps) {
5459
5489
  }
5460
5490
  var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
5461
5491
  var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
5492
+ var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
5462
5493
  function framesInput(input, fpsArg) {
5463
5494
  return [
5495
+ ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
5464
5496
  "-framerate",
5465
5497
  fpsArg,
5466
5498
  ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
@@ -5580,6 +5612,30 @@ function startEncodeProgress(job, stage, onProgress) {
5580
5612
  });
5581
5613
  return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
5582
5614
  }
5615
+ function resolveEngineConfig(input) {
5616
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
5617
+ }
5618
+ function videoEncoderOptions(input, engineCfg) {
5619
+ const { job, preset } = input;
5620
+ return {
5621
+ fps: job.config.fps,
5622
+ width: input.width,
5623
+ height: input.height,
5624
+ codec: preset.codec,
5625
+ preset: preset.preset,
5626
+ quality: input.effectiveQuality,
5627
+ bitrate: input.effectiveBitrate,
5628
+ pixelFormat: preset.pixelFormat,
5629
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
5630
+ useGpu: job.config.useGpu,
5631
+ hdr: preset.hdr,
5632
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
5633
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
5634
+ // renders leave both undefined → preserves the existing open-GOP output.
5635
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
5636
+ gopSize: input.gopSize
5637
+ };
5638
+ }
5583
5639
  async function runEncodeStage(input) {
5584
5640
  const {
5585
5641
  job,
@@ -5587,16 +5643,11 @@ async function runEncodeStage(input) {
5587
5643
  outputPath,
5588
5644
  framesDir,
5589
5645
  videoOnlyPath,
5590
- width,
5591
- height,
5592
5646
  needsAlpha,
5593
5647
  hasAudio,
5594
5648
  audioOutputPath,
5595
5649
  isPngSequence,
5596
5650
  isGif,
5597
- preset,
5598
- effectiveQuality,
5599
- effectiveBitrate,
5600
5651
  enableChunkedEncode,
5601
5652
  chunkedEncodeSize,
5602
5653
  abortSignal,
@@ -5626,7 +5677,7 @@ async function runEncodeStage(input) {
5626
5677
  encoded();
5627
5678
  return { encodeMs: Date.now() - stage5Start };
5628
5679
  }
5629
- const engineCfg = input.engineConfig ?? job.config.producerConfig ?? resolveConfig();
5680
+ const engineCfg = resolveEngineConfig(input);
5630
5681
  if (isGif) {
5631
5682
  const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
5632
5683
  if (hasAudio) {
@@ -5656,24 +5707,7 @@ async function runEncodeStage(input) {
5656
5707
  const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
5657
5708
  const frameExt = frameFileExtension2(input.captureImageFormat);
5658
5709
  const framePattern = `frame_%06d.${frameExt}`;
5659
- const encoderOpts = {
5660
- fps: job.config.fps,
5661
- width,
5662
- height,
5663
- codec: preset.codec,
5664
- preset: preset.preset,
5665
- quality: effectiveQuality,
5666
- bitrate: effectiveBitrate,
5667
- pixelFormat: preset.pixelFormat,
5668
- vp9CpuUsed: engineCfg.vp9CpuUsed,
5669
- useGpu: job.config.useGpu,
5670
- hdr: preset.hdr,
5671
- // Distributed chunk renders pass these so the encoder writes closed-GOP
5672
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
5673
- // renders leave both undefined → preserves the existing open-GOP output.
5674
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
5675
- gopSize: input.gopSize
5676
- };
5710
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
5677
5711
  const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
5678
5712
  framesDir,
5679
5713
  framePattern,
@@ -5710,7 +5744,8 @@ import { dirname as dirname6, join as join13 } from "node:path";
5710
5744
  import {
5711
5745
  DEFAULT_CONFIG,
5712
5746
  describeFfmpegFailure as describeFfmpegFailure2,
5713
- extractAudioMetadata as extractAudioMetadata2,
5747
+ encodeAac,
5748
+ extractAudioMetadata as extractAudioMetadata3,
5714
5749
  getFfprobeBinary,
5715
5750
  ManagedChildProcess,
5716
5751
  runFfmpeg as runFfmpeg5,
@@ -5719,7 +5754,12 @@ import {
5719
5754
  import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
5720
5755
 
5721
5756
  // src/services/render/audioTruePeak.ts
5722
- import { aacSegmentCount, describeFfmpegFailure, runFfmpeg as runFfmpeg4 } from "@hyperframes/engine";
5757
+ import {
5758
+ aacSegmentCount,
5759
+ describeFfmpegFailure,
5760
+ extractAudioMetadata as extractAudioMetadata2,
5761
+ runFfmpeg as runFfmpeg4
5762
+ } from "@hyperframes/engine";
5723
5763
  var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
5724
5764
  function checkedDbfs(value) {
5725
5765
  if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
@@ -5757,12 +5797,13 @@ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
5757
5797
  };
5758
5798
  }
5759
5799
  async function measureTruePeakDbfs(audioPath, options) {
5760
- const { timeoutMs, signal, segments } = options;
5800
+ const { timeoutMs, signal } = options;
5761
5801
  const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
5802
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
5762
5803
  const block = 4800;
5763
- const count = segments?.sampleRate === 48e3 ? aacSegmentCount(segments.durationSeconds) : 1;
5764
- if (!segments || count === 1) return wholeFile();
5765
- const cut = (k) => k * Math.floor(segments.durationSeconds * 10 / count) * block;
5804
+ const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
5805
+ if (count === 1) return wholeFile();
5806
+ const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
5766
5807
  const peers = new AbortController();
5767
5808
  const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
5768
5809
  const scans = await Promise.all(
@@ -5791,24 +5832,8 @@ var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
5791
5832
  var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
5792
5833
  var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
5793
5834
  var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
5794
- function buildAacTruePeakCorrectionArgs(inputPath, outputPath, targetDurationSeconds, attenuationDb) {
5795
- return [
5796
- "-i",
5797
- inputPath,
5798
- "-map",
5799
- "0:a:0",
5800
- "-vn",
5801
- "-af",
5802
- `volume=${attenuationDb.toFixed(3)}dB`,
5803
- "-t",
5804
- formatSeconds(targetDurationSeconds),
5805
- "-c:a",
5806
- "aac",
5807
- "-b:a",
5808
- "192k",
5809
- "-y",
5810
- outputPath
5811
- ];
5835
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
5836
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
5812
5837
  }
5813
5838
  function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
5814
5839
  const delta = targetDurationSeconds - sourceDurationSeconds;
@@ -5823,53 +5848,13 @@ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, tar
5823
5848
  if (delta > 0) {
5824
5849
  return {
5825
5850
  operation: "pad",
5826
- steps: [
5827
- {
5828
- kind: "normalize",
5829
- args: [
5830
- "-i",
5831
- audioPath,
5832
- "-af",
5833
- `apad,atrim=0:${targetSec}`,
5834
- "-t",
5835
- targetSec,
5836
- "-c:a",
5837
- "aac",
5838
- "-b:a",
5839
- "192k",
5840
- "-y",
5841
- outputPath
5842
- ]
5843
- }
5844
- ],
5851
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5845
5852
  cleanupPaths: []
5846
5853
  };
5847
5854
  }
5848
5855
  return {
5849
5856
  operation: "trim",
5850
- steps: [
5851
- {
5852
- kind: "trim",
5853
- args: [
5854
- "-i",
5855
- audioPath,
5856
- "-af",
5857
- `atrim=duration=${targetSec},asetpts=PTS-STARTPTS`,
5858
- // `atrim` limits decoded samples but does not cap muxer timestamps
5859
- // introduced by encoder delay/flush. The output duration contract
5860
- // is enforced at the container boundary as well; without `-t`, a
5861
- // long primed source can retain its original tail after re-encode.
5862
- "-t",
5863
- targetSec,
5864
- "-c:a",
5865
- "aac",
5866
- "-b:a",
5867
- "192k",
5868
- "-y",
5869
- outputPath
5870
- ]
5871
- }
5872
- ],
5857
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5873
5858
  cleanupPaths: []
5874
5859
  };
5875
5860
  }
@@ -5886,7 +5871,8 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5886
5871
  const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
5887
5872
  const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5888
5873
  const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
5889
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
5874
+ const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
5875
+ const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
5890
5876
  const [videoResult, audioResult] = await Promise.allSettled([
5891
5877
  probeVideo(input.videoPath),
5892
5878
  probeAudio(input.audioPath, input.signal)
@@ -5933,7 +5919,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5933
5919
  );
5934
5920
  try {
5935
5921
  for (const step of plan2.steps) {
5936
- const ffmpegResult = await runner(step.args);
5922
+ const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
5937
5923
  if (!ffmpegResult.success) {
5938
5924
  return {
5939
5925
  success: false,
@@ -5965,7 +5951,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5965
5951
  targetDurationSeconds,
5966
5952
  signal: input.signal,
5967
5953
  probeTruePeak,
5968
- runner
5954
+ encode
5969
5955
  });
5970
5956
  if (!correction.success) {
5971
5957
  return {
@@ -5995,12 +5981,8 @@ async function constrainAacTruePeak(input) {
5995
5981
  audioPath: input.audioPath,
5996
5982
  targetDurationSeconds: input.targetDurationSeconds,
5997
5983
  signal: input.signal,
5998
- probeTruePeak: async (audioPath, signal) => {
5999
- const { sampleRate } = await extractAudioMetadata2(audioPath, { signal });
6000
- const segments = { durationSeconds: input.targetDurationSeconds, sampleRate };
6001
- return measureTruePeakDbfs(audioPath, { timeoutMs, signal, segments });
6002
- },
6003
- runner: (args) => defaultRunFfmpeg(args, timeoutMs, input.signal)
5984
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
5985
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
6004
5986
  });
6005
5987
  }
6006
5988
  async function enforceAacTruePeak(input) {
@@ -6027,13 +6009,11 @@ async function enforceAacTruePeak(input) {
6027
6009
  }
6028
6010
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
6029
6011
  attenuationDb += correctionTargetDbfs - truePeakDbfs;
6030
- const result = await input.runner(
6031
- buildAacTruePeakCorrectionArgs(
6032
- input.audioPath,
6033
- correctedPath,
6034
- input.targetDurationSeconds,
6035
- attenuationDb
6036
- )
6012
+ const result = await input.encode(
6013
+ input.audioPath,
6014
+ correctedPath,
6015
+ input.targetDurationSeconds,
6016
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
6037
6017
  );
6038
6018
  if (!result.success) {
6039
6019
  return {
@@ -6128,7 +6108,7 @@ function parseFrameRate(rate) {
6128
6108
  return { fpsNum, fpsDen };
6129
6109
  }
6130
6110
  async function defaultProbeAudioInfo(audioPath, signal) {
6131
- const metadata = await extractAudioMetadata2(audioPath, { signal });
6111
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
6132
6112
  return {
6133
6113
  durationSeconds: metadata.durationSeconds,
6134
6114
  sampleRate: metadata.sampleRate,
@@ -6137,7 +6117,18 @@ async function defaultProbeAudioInfo(audioPath, signal) {
6137
6117
  };
6138
6118
  }
6139
6119
  async function defaultRunFfmpeg(args, timeoutMs, signal) {
6140
- const result = await runFfmpeg5(args, { signal, timeout: timeoutMs });
6120
+ return toStepResult(await runFfmpeg5(args, { signal, timeout: timeoutMs }), timeoutMs);
6121
+ }
6122
+ function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
6123
+ return async (inputPath, outputPath, durationSeconds, filter) => {
6124
+ const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
6125
+ return toStepResult(
6126
+ await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
6127
+ timeoutMs
6128
+ );
6129
+ };
6130
+ }
6131
+ function toStepResult(result, timeoutMs) {
6141
6132
  if (result.success) return { success: true };
6142
6133
  return {
6143
6134
  success: false,
@@ -6513,15 +6504,33 @@ function createRenderJob(config) {
6513
6504
  warnings: []
6514
6505
  };
6515
6506
  }
6507
+ function explainStreamingEncodeGate(cfg, outputFormat, workerCount, durationSeconds, forceParallelStream = false) {
6508
+ if (!cfg.enableStreamingEncode) return { enabled: false, reason: "disabled_by_config" };
6509
+ if (outputFormat === "png-sequence" || outputFormat === "gif") {
6510
+ return { enabled: false, reason: "format_excluded" };
6511
+ }
6512
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
6513
+ return { enabled: false, reason: "invalid_duration" };
6514
+ }
6515
+ const overCap = cfg.streamingEncodeDurationCapEnabled === true && durationSeconds > cfg.streamingEncodeMaxDurationSeconds;
6516
+ if (overCap && !cfg.lowMemoryMode) return { enabled: false, reason: "duration_cap" };
6517
+ if (forceParallelStream || process.env.HF_DE_PARALLEL_STREAM === "true") {
6518
+ return { enabled: true, reason: "parallel_forced" };
6519
+ }
6520
+ if (workerCount === 1) {
6521
+ return { enabled: true, reason: overCap ? "low_memory_mode" : "single_worker" };
6522
+ }
6523
+ return { enabled: false, reason: "multi_worker" };
6524
+ }
6516
6525
  var DEFAULT_SEGMENTED_MIN_SECONDS = Number.POSITIVE_INFINITY;
6517
6526
 
6518
6527
  // src/services/distributed/planFrameCost.ts
6519
6528
  import { join as join17 } from "node:path";
6520
6529
  import {
6521
6530
  closeCaptureSession as closeCaptureSession9,
6522
- createCaptureSession as createCaptureSession8,
6531
+ createCaptureSession as createCaptureSession9,
6523
6532
  createFrameLookupTable as createFrameLookupTable3,
6524
- initializeSession as initializeSession8
6533
+ initializeSession as initializeSession9
6525
6534
  } from "@hyperframes/engine";
6526
6535
 
6527
6536
  // src/services/distributed/chunkFrames.ts
@@ -6919,6 +6928,29 @@ function buildChunkCaptureOptions(input) {
6919
6928
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
6920
6929
  };
6921
6930
  }
6931
+ function shownDeferredFrameIndexes(planVideos, frames, fps) {
6932
+ const shown = /* @__PURE__ */ new Map();
6933
+ const deferred = planVideos.extracted.filter((video) => video.deferredRange);
6934
+ if (deferred.length === 0) return shown;
6935
+ const table = createFrameLookupTable2(
6936
+ planVideos.videos,
6937
+ deferred.map((video) => ({
6938
+ ...video,
6939
+ outputDir: "",
6940
+ framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)]))
6941
+ })),
6942
+ resolveRenderFpsConfig(fps).value
6943
+ );
6944
+ for (const frame of frames) {
6945
+ const globalTime = frame * fps.den / fps.num;
6946
+ for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
6947
+ const indexes = shown.get(videoId) ?? /* @__PURE__ */ new Set();
6948
+ indexes.add(payload.frameIndex);
6949
+ shown.set(videoId, indexes);
6950
+ }
6951
+ }
6952
+ return shown;
6953
+ }
6922
6954
  async function extractDeferredVideoFramesForChunk(input) {
6923
6955
  const deferred = input.planVideos.extracted.filter((video) => video.deferredRange);
6924
6956
  const remoteSources = input.remoteSources ?? {};
@@ -6935,24 +6967,7 @@ async function extractDeferredVideoFramesForChunk(input) {
6935
6967
  });
6936
6968
  }
6937
6969
  if (deferred.length === 0) return [];
6938
- const table = createFrameLookupTable2(
6939
- input.planVideos.videos,
6940
- deferred.map((video) => ({
6941
- ...video,
6942
- outputDir: input.outputRoot,
6943
- framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)]))
6944
- })),
6945
- resolveRenderFpsConfig(input.fps).value
6946
- );
6947
- const shown = /* @__PURE__ */ new Map();
6948
- for (const frame of input.frames) {
6949
- const globalTime = frame * input.fps.den / input.fps.num;
6950
- for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
6951
- const indexes = shown.get(videoId) ?? /* @__PURE__ */ new Set();
6952
- indexes.add(payload.frameIndex);
6953
- shown.set(videoId, indexes);
6954
- }
6955
- }
6970
+ const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
6956
6971
  return Promise.all(
6957
6972
  deferred.map(async (video) => {
6958
6973
  const outputDir = join16(input.outputRoot, video.videoId);
@@ -6978,7 +6993,8 @@ async function extractDeferredVideoFramesForChunk(input) {
6978
6993
  fps: input.fps,
6979
6994
  outputDir: input.outputRoot,
6980
6995
  format: range.format,
6981
- frameRanges: contiguousRuns(indexes)
6996
+ frameRanges: contiguousRuns(indexes),
6997
+ shownBox: input.shownBoxes?.get(video.videoId)
6982
6998
  },
6983
6999
  void 0,
6984
7000
  input.cfg
@@ -7025,7 +7041,7 @@ async function measurePlanSecondsPerFrame(input) {
7025
7041
  });
7026
7042
  let session = null;
7027
7043
  try {
7028
- session = await createCaptureSession8(
7044
+ session = await createCaptureSession9(
7029
7045
  fileServer.url,
7030
7046
  join17(workDir, "frame-cost"),
7031
7047
  buildChunkCaptureOptions({
@@ -7037,7 +7053,7 @@ async function measurePlanSecondsPerFrame(input) {
7037
7053
  createChunkVideoFrameInjectorFactory(lookup)(),
7038
7054
  createCaptureCalibrationConfig(input.cfg)
7039
7055
  );
7040
- await initializeSession8(session);
7056
+ await initializeSession9(session);
7041
7057
  const { estimate } = await measureCaptureCostFromSession(session, input.totalFrames, fps, log);
7042
7058
  return (estimate.p95Ms ?? 0) / 1e3;
7043
7059
  } finally {
@@ -7631,6 +7647,20 @@ function withDuration(buf, target, duration) {
7631
7647
  function timescaleOf(buf, header) {
7632
7648
  return buf.readUInt32BE(header.bodyStart + (buf[header.bodyStart] === 1 ? 20 : 12));
7633
7649
  }
7650
+ var TRACK_ID_AT = { tkhd: [12, 20], mvhd: [96, 108] };
7651
+ function idOf(buf, target) {
7652
+ return buf.readUInt32BE(
7653
+ target.bodyStart + TRACK_ID_AT[target.type][buf[target.bodyStart] === 1 ? 1 : 0]
7654
+ );
7655
+ }
7656
+ function withId(boxBytes, type, id) {
7657
+ const header = boxAt(boxBytes, 0, 0, boxBytes.length);
7658
+ boxBytes.writeUInt32BE(
7659
+ id,
7660
+ header.bodyStart + TRACK_ID_AT[type][boxBytes[header.bodyStart] === 1 ? 1 : 0]
7661
+ );
7662
+ return boxBytes;
7663
+ }
7634
7664
  function buildStbl(stsd, track) {
7635
7665
  const stts = runs(track.durations);
7636
7666
  const stsc = [];
@@ -7672,24 +7702,48 @@ function planTracksOf(tracks, plan2, index) {
7672
7702
  }
7673
7703
  return matched;
7674
7704
  }
7675
- function buildStitchedHeader(layouts) {
7676
- const plan2 = readTracks(layouts[0].moov);
7677
- const chunks = layouts.map((layout, i) => planTracksOf(readTracks(layout.moov), plan2, i));
7705
+ function sourceTracks(layouts, audio) {
7706
+ const chunkPlan = readTracks(layouts[0].moov);
7707
+ const audioTracks = audio ? readTracks(audio.moov) : [];
7708
+ if (audio && (audioTracks.length !== 1 || audioTracks[0].handler !== "soun")) {
7709
+ throw new Error("[mp4Stitch] the separate audio file must hold one audio track");
7710
+ }
7711
+ const plan2 = [...chunkPlan, ...audioTracks];
7712
+ const chunks = layouts.map((layout, i) => {
7713
+ const tracks = readTracks(layout.moov);
7714
+ if (audio && tracks.some((track) => track.handler === "soun")) {
7715
+ throw new Error(`[mp4Stitch] chunk ${i} carries its own audio`);
7716
+ }
7717
+ return planTracksOf(tracks, plan2, i);
7718
+ });
7719
+ return {
7720
+ plan: plan2,
7721
+ chunkTrackCount: chunkPlan.length,
7722
+ sources: audio ? [[...chunkPlan.map(() => null), audioTracks[0]], ...chunks] : chunks
7723
+ };
7724
+ }
7725
+ function buildStitchedHeader(layouts, audio) {
7726
+ const { plan: plan2, chunkTrackCount, sources } = sourceTracks(layouts, audio);
7727
+ const sourceLayouts = audio ? [audio, ...layouts] : layouts;
7678
7728
  const payloadBases = [];
7679
7729
  let payloadBytes = 0;
7680
- for (const layout of layouts) {
7730
+ for (const layout of sourceLayouts) {
7681
7731
  payloadBases.push(payloadBytes);
7682
7732
  payloadBytes += layout.payloadEnd - layout.payloadStart;
7683
7733
  }
7684
7734
  const template = layouts[0].moov;
7685
7735
  const root = boxAt(template, 0, 0, template.length);
7686
- const movieTimescale = timescaleOf(template, child(template, root, "mvhd"));
7736
+ const mvhd = child(template, root, "mvhd");
7737
+ const movieTimescale = timescaleOf(template, mvhd);
7738
+ const chunkTrackIds = plan2.slice(0, chunkTrackCount).map((track) => idOf(template, child(template, track.trak, "tkhd")));
7739
+ const audioTrackId = Math.max(...chunkTrackIds) + 1;
7687
7740
  const buildMoov = (dataStart2) => {
7688
7741
  const traks = plan2.map((track, t) => {
7689
- const parts = chunks.flatMap((tracks, chunk) => {
7742
+ const parts = sources.flatMap((tracks, chunk) => {
7690
7743
  const part = tracks[t];
7691
7744
  return part ? [{ ...part, chunk }] : [];
7692
7745
  });
7746
+ const moov = t < chunkTrackCount ? template : audio.moov;
7693
7747
  const merged = {
7694
7748
  durations: parts.flatMap((part) => part.durations),
7695
7749
  sizes: parts.flatMap((part) => part.sizes),
@@ -7698,9 +7752,9 @@ function buildStitchedHeader(layouts) {
7698
7752
  chunkOffsets: parts.flatMap(
7699
7753
  (part) => part.chunkOffsets.map((offset) => {
7700
7754
  const i = part.chunk;
7701
- const { payloadStart, payloadEnd } = layouts[i];
7755
+ const { payloadStart, payloadEnd } = sourceLayouts[i];
7702
7756
  if (offset < payloadStart || offset >= payloadEnd) {
7703
- throw new Error(`[mp4Stitch] chunk ${i} has samples outside its mdat`);
7757
+ throw new Error(`[mp4Stitch] source ${i} has samples outside its mdat`);
7704
7758
  }
7705
7759
  return offset - payloadStart + payloadBases[i] + dataStart2;
7706
7760
  })
@@ -7716,20 +7770,25 @@ function buildStitchedHeader(layouts) {
7716
7770
  elst.writeBigInt64BE(BigInt(track.mediaTime), 12);
7717
7771
  elst.writeUInt32BE(65536, 20);
7718
7772
  const edts = track.mediaTime ? box("edts", fullBox("elst", 1, elst)) : Buffer.alloc(0);
7719
- const trak = rebuild(template, track.trak, {
7720
- tkhd: (tkhd) => Buffer.concat([withDuration(template, tkhd, shown), edts]),
7773
+ const tkhdOf = (tkhd) => {
7774
+ const timed = withDuration(moov, tkhd, shown);
7775
+ return t < chunkTrackCount ? timed : withId(timed, "tkhd", audioTrackId);
7776
+ };
7777
+ const trak = rebuild(moov, track.trak, {
7778
+ tkhd: (tkhd) => Buffer.concat([tkhdOf(tkhd), edts]),
7721
7779
  edts: () => Buffer.alloc(0),
7722
- mdia: (mdia) => rebuild(template, mdia, {
7723
- mdhd: (mdhd) => withDuration(template, mdhd, mediaDuration),
7724
- minf: (minf) => rebuild(template, minf, { stbl: () => buildStbl(track.stsd, merged) })
7780
+ mdia: (mdia) => rebuild(moov, mdia, {
7781
+ mdhd: (mdhd) => withDuration(moov, mdhd, mediaDuration),
7782
+ minf: (minf) => rebuild(moov, minf, { stbl: () => buildStbl(track.stsd, merged) })
7725
7783
  })
7726
7784
  });
7727
7785
  return { trak, shown };
7728
7786
  });
7729
7787
  let next = 0;
7788
+ const movieHeader = withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown)));
7730
7789
  return rebuild(template, root, {
7731
- mvhd: (mvhd) => withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown))),
7732
- trak: () => traks[next++].trak
7790
+ mvhd: () => audio ? withId(movieHeader, "mvhd", audioTrackId + 1) : movieHeader,
7791
+ trak: () => ++next < chunkTrackCount ? traks[next - 1].trak : Buffer.concat(traks.slice(next - 1).map((t) => t.trak))
7733
7792
  });
7734
7793
  };
7735
7794
  const ftyp = layouts[0].ftyp;
@@ -7762,15 +7821,16 @@ async function copyPayload(path, layout, index, out) {
7762
7821
  );
7763
7822
  }
7764
7823
  }
7765
- async function writeStitchedMp4(chunkPaths, outputPath) {
7766
- const layouts = await Promise.all(chunkPaths.map((path) => readMp4Layout(fileRange(path))));
7767
- const header = buildStitchedHeader(layouts);
7824
+ async function writeStitchedMp4(chunkPaths, outputPath, audioPath) {
7825
+ const sourcePaths = audioPath ? [audioPath, ...chunkPaths] : chunkPaths;
7826
+ const layouts = await Promise.all(sourcePaths.map((path) => readMp4Layout(fileRange(path))));
7827
+ const header = audioPath ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
7768
7828
  mkdirSync8(dirname9(outputPath), { recursive: true });
7769
7829
  const workDir = mkdtempSync3(`${outputPath}.stitch-`);
7770
7830
  const out = createWriteStream(join19(workDir, "out.mp4"));
7771
7831
  try {
7772
7832
  out.write(header);
7773
- for (const [i, path] of chunkPaths.entries()) await copyPayload(path, layouts[i], i, out);
7833
+ for (const [i, path] of sourcePaths.entries()) await copyPayload(path, layouts[i], i, out);
7774
7834
  out.end();
7775
7835
  await finished(out);
7776
7836
  renameSync3(join19(workDir, "out.mp4"), outputPath);
@@ -8345,7 +8405,8 @@ function buildLockedRenderConfig(input) {
8345
8405
  chunkCount: input.chunkCount,
8346
8406
  runtimeEnv: input.runtimeEnv,
8347
8407
  variables: config.variables,
8348
- ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {}
8408
+ ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
8409
+ ...input.separateAudio ? { separateAudio: true } : {}
8349
8410
  };
8350
8411
  }
8351
8412
  function carriesChunkAudio(config, options) {
@@ -8440,8 +8501,10 @@ function createVideoSourceShipper(input) {
8440
8501
  return result;
8441
8502
  };
8442
8503
  }
8443
- async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8444
- const planDir = executionPlanDir;
8504
+ function plansSeparateAudio(config, options) {
8505
+ return options.deferAudio === true && config.format === "mp4" && config.cfr !== true;
8506
+ }
8507
+ async function compilePlanComposition(projectDir, config, planDir, options) {
8445
8508
  rejectUnsupportedDistributedFormat(config);
8446
8509
  validateNoGpuEncode({
8447
8510
  useGpu: false,
@@ -8577,6 +8640,100 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8577
8640
  });
8578
8641
  }
8579
8642
  }
8643
+ return {
8644
+ projectDir,
8645
+ log,
8646
+ sizeLimitBytes,
8647
+ abortSignal,
8648
+ assertNotAborted,
8649
+ cfg,
8650
+ job,
8651
+ workDir,
8652
+ compiledDir,
8653
+ finalCompiledDir,
8654
+ remoteFiles,
8655
+ urlByAbsentLocalPath,
8656
+ localPathByUrl,
8657
+ compileResult,
8658
+ compiled,
8659
+ composition,
8660
+ deviceScaleFactor,
8661
+ forceScreenshot,
8662
+ width,
8663
+ height,
8664
+ probeResult,
8665
+ totalFrames
8666
+ };
8667
+ }
8668
+ function mixPlanAudio(c, compiledDir, seconds, pcmPath) {
8669
+ return runAudioStage({
8670
+ projectDir: c.projectDir,
8671
+ workDir: c.workDir,
8672
+ compiledDir,
8673
+ urlByAbsentLocalPath: c.urlByAbsentLocalPath,
8674
+ duration: seconds,
8675
+ ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
8676
+ audioGain: c.cfg.audioGain,
8677
+ audios: c.composition.audios,
8678
+ masterFxChain: c.composition.masterFxChain,
8679
+ ...pcmPath ? { keepEncoderInputAt: pcmPath } : {},
8680
+ abortSignal: c.abortSignal,
8681
+ assertNotAborted: c.assertNotAborted
8682
+ });
8683
+ }
8684
+ async function extractPlanVideos(c, deferRangeExtraction) {
8685
+ const extractResult = await runExtractVideosStage({
8686
+ projectDir: c.projectDir,
8687
+ compiledDir: c.compiledDir,
8688
+ job: c.job,
8689
+ cfg: c.cfg,
8690
+ composition: c.composition,
8691
+ abortSignal: c.abortSignal,
8692
+ assertNotAborted: c.assertNotAborted,
8693
+ materializeSymlinks: true,
8694
+ deferRangeExtraction,
8695
+ urlByAbsentLocalPath: c.urlByAbsentLocalPath
8696
+ });
8697
+ if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
8698
+ if (extractResult.extractionResult) {
8699
+ assertVideoExtractionSucceeded(extractResult.extractionResult);
8700
+ }
8701
+ return extractResult;
8702
+ }
8703
+ async function holdPlanAudioPeak(c, audioPath, pcmPath, seconds) {
8704
+ const peak = await constrainAacTruePeak({
8705
+ audioPath,
8706
+ ...existsSync15(pcmPath) ? { sourcePath: pcmPath } : {},
8707
+ targetDurationSeconds: seconds,
8708
+ signal: c.abortSignal,
8709
+ timeoutMs: c.cfg.ffmpegProcessTimeout
8710
+ }).finally(() => rmSync8(pcmPath, { force: true }));
8711
+ if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
8712
+ }
8713
+ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8714
+ const planDir = executionPlanDir;
8715
+ const c = await compilePlanComposition(projectDir, config, planDir, options);
8716
+ const {
8717
+ log,
8718
+ sizeLimitBytes,
8719
+ cfg,
8720
+ job,
8721
+ workDir,
8722
+ compiledDir,
8723
+ finalCompiledDir,
8724
+ remoteFiles,
8725
+ urlByAbsentLocalPath,
8726
+ localPathByUrl,
8727
+ compileResult,
8728
+ compiled,
8729
+ composition,
8730
+ deviceScaleFactor,
8731
+ forceScreenshot,
8732
+ width,
8733
+ height,
8734
+ probeResult,
8735
+ totalFrames
8736
+ } = c;
8580
8737
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
8581
8738
  let maxParallel = resolveMaxParallelChunks(config, totalFrames, megapixels, log);
8582
8739
  const measureFrameCost = chunkSizingUsesFrameCost(config) && planNeedsFrameCostMeasurement(compiled.html);
@@ -8587,24 +8744,9 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8587
8744
  observedAt: "pre-extract",
8588
8745
  log
8589
8746
  });
8590
- stageStarted = Date.now();
8591
- const extractResult = await runExtractVideosStage({
8592
- projectDir,
8593
- compiledDir,
8594
- job,
8595
- cfg,
8596
- composition,
8597
- abortSignal,
8598
- assertNotAborted,
8599
- materializeSymlinks: true,
8600
- deferRangeExtraction: options.deferVideoExtraction,
8601
- urlByAbsentLocalPath
8602
- });
8747
+ let stageStarted = Date.now();
8748
+ const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
8603
8749
  logPlanStage(log, "extract", stageStarted);
8604
- if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
8605
- if (extractResult.extractionResult) {
8606
- assertVideoExtractionSucceeded(extractResult.extractionResult);
8607
- }
8608
8750
  const stagedVideoFrames = join22(compiledDir, "__hyperframes_video_frames");
8609
8751
  const videoFramesDst = join22(planDir, "video-frames");
8610
8752
  if (existsSync15(videoFramesDst)) rmSync8(videoFramesDst, { recursive: true, force: true });
@@ -8660,23 +8802,18 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8660
8802
  "utf-8"
8661
8803
  );
8662
8804
  const digests = options.digests ?? /* @__PURE__ */ new Map();
8663
- const chunksCarryAudio2 = carriesChunkAudio(config, options);
8664
- const audioSeconds = chunksCarryAudio2 ? totalFrames / config.fps : probeResult.duration;
8805
+ const separateAudio = plansSeparateAudio(config, options);
8806
+ const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options);
8807
+ const audioSeconds = chunksCarryAudio ? totalFrames / config.fps : probeResult.duration;
8808
+ const planMixPcmPath = join22(workDir, "plan-mix.wav");
8665
8809
  stageStarted = Date.now();
8666
8810
  const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
8667
- runAudioStage({
8668
- projectDir,
8669
- workDir,
8670
- compiledDir: finalCompiledDir,
8671
- urlByAbsentLocalPath,
8672
- duration: audioSeconds,
8673
- ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
8674
- audioGain: cfg.audioGain,
8675
- audios: composition.audios,
8676
- masterFxChain: composition.masterFxChain,
8677
- abortSignal,
8678
- assertNotAborted
8679
- }).finally(() => logPlanStage(log, "audio", stageStarted)),
8811
+ separateAudio ? null : mixPlanAudio(
8812
+ c,
8813
+ finalCompiledDir,
8814
+ audioSeconds,
8815
+ chunksCarryAudio ? planMixPcmPath : void 0
8816
+ ).finally(() => logPlanStage(log, "audio", stageStarted)),
8680
8817
  (async () => {
8681
8818
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
8682
8819
  await prehashPlanFiles(dir, digests);
@@ -8726,22 +8863,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8726
8863
  );
8727
8864
  const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
8728
8865
  const audioResult = audioOutcome.value;
8729
- if (audioResult.audioError) {
8866
+ if (audioResult?.audioError) {
8730
8867
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
8731
8868
  }
8732
8869
  const planAudioPath = join22(planDir, PLAN_AUDIO_RELATIVE_PATH);
8733
- if (audioResult.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8870
+ if (audioResult?.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8734
8871
  renameSync4(audioResult.audioOutputPath, planAudioPath);
8735
8872
  }
8736
- if (chunksCarryAudio2 && existsSync15(planAudioPath)) {
8873
+ if (chunksCarryAudio && existsSync15(planAudioPath)) {
8737
8874
  stageStarted = Date.now();
8738
- const peak = await constrainAacTruePeak({
8739
- audioPath: planAudioPath,
8740
- targetDurationSeconds: audioSeconds,
8741
- signal: abortSignal,
8742
- timeoutMs: cfg.ffmpegProcessTimeout
8743
- });
8744
- if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
8875
+ await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
8745
8876
  logPlanStage(log, "audio-peak", stageStarted);
8746
8877
  if (options.sliceChunkAudio) {
8747
8878
  stageStarted = Date.now();
@@ -8767,7 +8898,8 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8767
8898
  effectiveChunkSize,
8768
8899
  chunkCount,
8769
8900
  runtimeEnv,
8770
- chunksCarryAudio: chunksCarryAudio2
8901
+ chunksCarryAudio,
8902
+ separateAudio
8771
8903
  });
8772
8904
  const compositionJson = {
8773
8905
  durationSeconds: job.duration ?? 0,
@@ -8812,7 +8944,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8812
8944
  fontSnapshotSha,
8813
8945
  durationSeconds: job.duration ?? 0,
8814
8946
  totalFrames,
8815
- hasAudio: audioResult.hasAudio,
8947
+ hasAudio: audioResult?.hasAudio ?? false,
8816
8948
  digests
8817
8949
  });
8818
8950
  logPlanStage(log, "freeze", stageStarted);
@@ -8837,6 +8969,33 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8837
8969
  producerVersion
8838
8970
  };
8839
8971
  }
8972
+ async function buildPlanAudio(projectDir, config, workRoot, outputPath, options = {}) {
8973
+ const c = await compilePlanComposition(projectDir, config, workRoot, {
8974
+ ...options,
8975
+ executionPlanSizeLimitBytes: Number.MAX_SAFE_INTEGER
8976
+ });
8977
+ try {
8978
+ const seconds = c.totalFrames / config.fps;
8979
+ const pcmPath = join22(c.workDir, "plan-mix.wav");
8980
+ let stageStarted = Date.now();
8981
+ await extractPlanVideos(c, true);
8982
+ logPlanStage(c.log, "extract", stageStarted);
8983
+ stageStarted = Date.now();
8984
+ const mixed = await mixPlanAudio(c, c.compiledDir, seconds, pcmPath);
8985
+ logPlanStage(c.log, "audio", stageStarted);
8986
+ if (mixed.audioError) {
8987
+ applyDistributedAudioWarningPolicy(c.job, mixed.audioError, mixed.audioFailures, c.log);
8988
+ }
8989
+ if (!mixed.hasAudio || !existsSync15(mixed.audioOutputPath)) return { hasAudio: false };
8990
+ renameSync4(mixed.audioOutputPath, outputPath);
8991
+ stageStarted = Date.now();
8992
+ await holdPlanAudioPeak(c, outputPath, pcmPath, seconds);
8993
+ logPlanStage(c.log, "audio-peak", stageStarted);
8994
+ return { hasAudio: true };
8995
+ } finally {
8996
+ rmSync8(c.workDir, { recursive: true, force: true });
8997
+ }
8998
+ }
8840
8999
  async function plan(projectDir, config, planDir, options = {}) {
8841
9000
  const executionPlan = await buildLocalExecutionPlan(projectDir, config, planDir, options);
8842
9001
  return {
@@ -9445,7 +9604,8 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9445
9604
  )
9446
9605
  ),
9447
9606
  chunksCarryAudio: options.chunksCarryAudio,
9448
- sliceChunkAudio: true
9607
+ sliceChunkAudio: true,
9608
+ deferAudio: options.deferAudio
9449
9609
  });
9450
9610
  return await publishExecutionPlan(stagingRoot, publisher, { digests, log: config.logger });
9451
9611
  } catch (error) {
@@ -9781,7 +9941,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
9781
9941
  return mergePngFrameDirs(chunkPaths, outputPath, plan2.totalFrames, audioPath, start);
9782
9942
  }
9783
9943
  if (chunksAreStitchedMp4(planDir, cfr)) {
9784
- await writeStitchedMp4(chunkPaths, outputPath);
9944
+ const separateAudio = stitchesSeparateAudio(planDir) && audioPath !== null;
9945
+ await writeStitchedMp4(chunkPaths, outputPath, separateAudio ? audioPath : void 0);
9785
9946
  return {
9786
9947
  outputPath,
9787
9948
  durationMs: Date.now() - start,
@@ -9979,16 +10140,20 @@ async function runAssembleFfmpeg(args, step, signal, timeout) {
9979
10140
  }
9980
10141
  }
9981
10142
  function chunksAreStitchedMp4(planDir, cfr) {
9982
- if (!chunksCarryAudio(planDir)) return false;
10143
+ const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
10144
+ if (!chunksCarryAudio && !separateAudio) return false;
9983
10145
  if (cfr) {
9984
- throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks carry audio");
10146
+ throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks are stitched");
9985
10147
  }
9986
10148
  return true;
9987
10149
  }
9988
- function chunksCarryAudio(planDir) {
10150
+ function stitchesSeparateAudio(planDir) {
10151
+ return lockedAudio(planDir).separateAudio === true;
10152
+ }
10153
+ function lockedAudio(planDir) {
9989
10154
  const encoderJsonPath = join26(planDir, "meta", "encoder.json");
9990
- if (!existsSync18(encoderJsonPath)) return false;
9991
- return JSON.parse(readFileSync11(encoderJsonPath, "utf-8")).chunksCarryAudio === true;
10155
+ if (!existsSync18(encoderJsonPath)) return {};
10156
+ return JSON.parse(readFileSync11(encoderJsonPath, "utf-8"));
9992
10157
  }
9993
10158
  function readCfrEncoder(planDir, format) {
9994
10159
  if (format !== "mp4") {
@@ -10062,11 +10227,12 @@ import {
10062
10227
  calculateOptimalWorkers as calculateOptimalWorkers2,
10063
10228
  classifyCaptureFailure as classifyCaptureFailure4,
10064
10229
  closeCaptureSession as closeCaptureSession10,
10065
- createCaptureSession as createCaptureSession9,
10230
+ createCaptureSession as createCaptureSession10,
10066
10231
  createFrameLookupTable as createFrameLookupTable5,
10067
10232
  deriveBeginFrameProbeTimeTicks as deriveBeginFrameProbeTimeTicks2,
10068
10233
  getEncoderPreset as getEncoderPreset6,
10069
- initializeSession as initializeSession9,
10234
+ initializeSession as initializeSession10,
10235
+ measureVideoShownBoxes,
10070
10236
  probeBeginFrameLiveness as probeBeginFrameLiveness2,
10071
10237
  readWebGlVendorInfoFromCanvas,
10072
10238
  resolveConfig as resolveConfig5
@@ -10083,6 +10249,12 @@ function validatePlanVideosForChunk(value) {
10083
10249
  );
10084
10250
  }
10085
10251
  }
10252
+ var MIN_FRAMES_PER_CHUNK_BROWSER = 20;
10253
+ function resolveChunkCaptureWorkers(framesInChunk, machineSized, maxCaptureWorkers) {
10254
+ if (maxCaptureWorkers === void 0) return machineSized;
10255
+ const byFrames = Math.floor(framesInChunk / MIN_FRAMES_PER_CHUNK_BROWSER);
10256
+ return Math.max(machineSized, Math.floor(Math.min(maxCaptureWorkers, byFrames)));
10257
+ }
10086
10258
  function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
10087
10259
  if (frameRange === void 0) return { startFrame: slice.startFrame, endFrame: slice.endFrame };
10088
10260
  const { startFrame, endFrame } = frameRange;
@@ -10095,9 +10267,9 @@ function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
10095
10267
  return { startFrame, endFrame };
10096
10268
  }
10097
10269
  var distributedCaptureSessionDependencies = {
10098
- createCaptureSession: createCaptureSession9,
10270
+ createCaptureSession: createCaptureSession10,
10099
10271
  assertSwiftShader,
10100
- initializeSession: initializeSession9,
10272
+ initializeSession: initializeSession10,
10101
10273
  closeCaptureSession: closeCaptureSession10,
10102
10274
  readWebGlVendorInfo: readWebGlVendorInfoFromCanvas
10103
10275
  };
@@ -10288,34 +10460,15 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10288
10460
  const framesDir = join27(workDir, "captured-frames");
10289
10461
  mkdirSync14(framesDir, { recursive: true });
10290
10462
  const videoFramesDir = `${workDir}.video-frames`;
10291
- const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
10292
- planDir,
10293
- planVideos,
10294
- frames: Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i),
10295
- fps: { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen },
10296
- outputRoot: videoFramesDir,
10297
- cfg,
10298
- remoteSources
10299
- }) : [];
10463
+ const chunkFrameNumbers = Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i);
10464
+ const chunkFps = { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen };
10465
+ const deferredVideos = planVideos?.extracted.filter((video) => video.deferredRange) ?? [];
10466
+ const shownFrames = planVideos ? shownDeferredFrameIndexes(planVideos, chunkFrameNumbers, chunkFps) : /* @__PURE__ */ new Map();
10300
10467
  const remoteVideoIds = new Set(
10301
10468
  planVideos?.extracted.filter(
10302
10469
  (video) => video.deferredRange && Object.hasOwn(remoteSources, video.deferredRange.sourcePath)
10303
10470
  ).map((video) => video.videoId)
10304
10471
  );
10305
- const remoteVideos = deferredVideoFrames.filter((video) => remoteVideoIds.has(video.videoId));
10306
- const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
10307
- planVideos.videos,
10308
- [
10309
- ...rebuildExtractedFramesFromPlanDir(
10310
- planDir,
10311
- planVideos.extracted.filter((video) => !video.deferredRange),
10312
- v2Manifest === null ? "dense-v1" : "sparse-v2"
10313
- ),
10314
- ...deferredVideoFrames
10315
- ],
10316
- resolveRenderFpsConfig(job.config.fps).value
10317
- ) : null;
10318
- const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
10319
10472
  const fileServer = await createFileServer({
10320
10473
  projectDir: compiledDir,
10321
10474
  compiledDir,
@@ -10331,10 +10484,18 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10331
10484
  videoCount: planVideos?.videos.length ?? 0,
10332
10485
  html: readFileSync12(join27(compiledDir, "index.html"), "utf-8"),
10333
10486
  // The plan ships a deferred source only to chunks that show it; don't wait on the rest.
10334
- skipReadinessVideoIds: deferredVideoFrames.filter((video) => video.framePaths.size === 0 || remoteVideoIds.has(video.videoId)).map((video) => video.videoId),
10335
- videoMetadataHints: collectVideoMetadataHints(remoteVideos)
10487
+ skipReadinessVideoIds: deferredVideos.filter(
10488
+ (video) => !shownFrames.get(video.videoId)?.size || remoteVideoIds.has(video.videoId)
10489
+ ).map((video) => video.videoId),
10490
+ videoMetadataHints: collectVideoMetadataHints(
10491
+ deferredVideos.filter((video) => remoteVideoIds.has(video.videoId))
10492
+ )
10336
10493
  });
10337
- const chunkWorkerCount = calculateOptimalWorkers2(framesInChunk, void 0, cfg);
10494
+ const chunkWorkerCount = resolveChunkCaptureWorkers(
10495
+ framesInChunk,
10496
+ calculateOptimalWorkers2(framesInChunk, void 0, cfg),
10497
+ options?.maxCaptureWorkers
10498
+ );
10338
10499
  let session = null;
10339
10500
  let outputKind;
10340
10501
  let framesEncoded = 0;
@@ -10345,6 +10506,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10345
10506
  const capturePerfs = [];
10346
10507
  const captureAttempts = [];
10347
10508
  let forceScreenshotForChunk = encoder.forceScreenshot;
10509
+ let shownBoxes;
10348
10510
  try {
10349
10511
  if (chunkWorkerCount === 1 || !forceScreenshotForChunk) {
10350
10512
  const bootStarted = Date.now();
@@ -10355,6 +10517,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10355
10517
  cfg
10356
10518
  );
10357
10519
  sessionBootMs = Date.now() - bootStarted;
10520
+ shownBoxes = await measureVideoShownBoxes(
10521
+ session.page,
10522
+ [...shownFrames.keys()],
10523
+ chunkFrameNumbers.map((frame) => frame * chunkFps.den / chunkFps.num)
10524
+ );
10358
10525
  const browserSelectedScreenshot = session.launchCaptureMode === "screenshot";
10359
10526
  const beginFrameStalled = !browserSelectedScreenshot && await beginFrameSessionNeedsScreenshotFallback(session);
10360
10527
  if (browserSelectedScreenshot) {
@@ -10396,6 +10563,29 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10396
10563
  session = null;
10397
10564
  }
10398
10565
  }
10566
+ const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
10567
+ planDir,
10568
+ planVideos,
10569
+ frames: chunkFrameNumbers,
10570
+ fps: chunkFps,
10571
+ outputRoot: videoFramesDir,
10572
+ cfg,
10573
+ remoteSources,
10574
+ shownBoxes
10575
+ }) : [];
10576
+ const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
10577
+ planVideos.videos,
10578
+ [
10579
+ ...rebuildExtractedFramesFromPlanDir(
10580
+ planDir,
10581
+ planVideos.extracted.filter((video) => !video.deferredRange),
10582
+ v2Manifest === null ? "dense-v1" : "sparse-v2"
10583
+ ),
10584
+ ...deferredVideoFrames
10585
+ ],
10586
+ resolveRenderFpsConfig(job.config.fps).value
10587
+ ) : null;
10588
+ const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
10399
10589
  const captureStarted = Date.now();
10400
10590
  captureMode = await runCaptureWithScreenshotFallback({
10401
10591
  forceScreenshot: forceScreenshotForChunk,
@@ -10586,6 +10776,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10586
10776
  encodeStageMs,
10587
10777
  workers: chunkWorkerCount,
10588
10778
  captureMode,
10779
+ // Device-pixel box each video's frames were extracted to cover; a video not listed kept full size.
10780
+ videoShownBoxes: Object.fromEntries(shownBoxes ?? []),
10589
10781
  sha256,
10590
10782
  outputKind,
10591
10783
  producerVersion: plan2.producerVersion,
@@ -10897,10 +11089,11 @@ function validateIntDimension(field, value) {
10897
11089
  }
10898
11090
 
10899
11091
  // src/services/distributed/projectHash.ts
10900
- import { readdirSync as readdirSync10, readFileSync as readFileSync13, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
11092
+ import { closeSync as closeSync5, openSync as openSync5, readdirSync as readdirSync10, readSync as readSync3, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
10901
11093
  import { createHash as createHash6 } from "node:crypto";
10902
11094
  import { join as join29 } from "node:path";
10903
11095
  var SITE_ID_SCHEME = "site-v2\0";
11096
+ var READ_CHUNK_BYTES = 256 * 1024;
10904
11097
  function listSiteFiles(projectDir) {
10905
11098
  const files = [];
10906
11099
  const ancestors = /* @__PURE__ */ new Set([realpathSync2(projectDir)]);
@@ -10933,10 +11126,19 @@ function listSiteFiles(projectDir) {
10933
11126
  }
10934
11127
  function hashProjectDir(projectDir, files = listSiteFiles(projectDir)) {
10935
11128
  const hash = createHash6("sha256").update(SITE_ID_SCHEME);
11129
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
10936
11130
  for (const rel of files) {
10937
11131
  hash.update(rel);
10938
11132
  hash.update("\0");
10939
- hash.update(readFileSync13(join29(projectDir, rel)));
11133
+ const fd = openSync5(join29(projectDir, rel), "r");
11134
+ try {
11135
+ let read;
11136
+ while ((read = readSync3(fd, chunk, 0, chunk.length, null)) > 0) {
11137
+ hash.update(chunk.subarray(0, read));
11138
+ }
11139
+ } finally {
11140
+ closeSync5(fd);
11141
+ }
10940
11142
  }
10941
11143
  return hash.digest("hex").slice(0, 16);
10942
11144
  }
@@ -10984,6 +11186,7 @@ export {
10984
11186
  assemble,
10985
11187
  assembleV2,
10986
11188
  buildChunkSlices,
11189
+ buildPlanAudio,
10987
11190
  buildStitchedHeader,
10988
11191
  chunksAreStitchedMp4,
10989
11192
  createPlanV2FromExecutionPlan,
@@ -11001,6 +11204,7 @@ export {
11001
11204
  plan,
11002
11205
  planV2,
11003
11206
  planV2WithPublisher,
11207
+ plansSeparateAudio,
11004
11208
  publishPlanV2FromExecutionPlan,
11005
11209
  publishPlanV2FromV1,
11006
11210
  readMp4Layout,
@@ -11013,6 +11217,7 @@ export {
11013
11217
  renderChunkV2,
11014
11218
  resolveChunkPlan,
11015
11219
  resolvePlanAudioPath,
11220
+ stitchesSeparateAudio,
11016
11221
  validateDistributedRenderConfig,
11017
11222
  validatePlanV2MaterializedTarget,
11018
11223
  validateVariablesPayload