@hyperframes/producer 0.8.180 → 0.8.183

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({
@@ -5459,8 +5487,10 @@ function fpsToFfmpegArg2(fps) {
5459
5487
  }
5460
5488
  var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
5461
5489
  var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
5490
+ var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
5462
5491
  function framesInput(input, fpsArg) {
5463
5492
  return [
5493
+ ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
5464
5494
  "-framerate",
5465
5495
  fpsArg,
5466
5496
  ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
@@ -5709,36 +5739,97 @@ import { mkdtempSync as mkdtempSync2, renameSync as renameSync2, rmSync as rmSyn
5709
5739
  import { dirname as dirname6, join as join13 } from "node:path";
5710
5740
  import {
5711
5741
  DEFAULT_CONFIG,
5712
- describeFfmpegFailure,
5713
- extractAudioMetadata as extractAudioMetadata2,
5742
+ describeFfmpegFailure as describeFfmpegFailure2,
5743
+ encodeAac,
5744
+ extractAudioMetadata as extractAudioMetadata3,
5714
5745
  getFfprobeBinary,
5715
5746
  ManagedChildProcess,
5716
- runFfmpeg as runFfmpeg4,
5747
+ runFfmpeg as runFfmpeg5,
5717
5748
  trackChildProcess
5718
5749
  } from "@hyperframes/engine";
5719
5750
  import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
5751
+
5752
+ // src/services/render/audioTruePeak.ts
5753
+ import {
5754
+ aacSegmentCount,
5755
+ describeFfmpegFailure,
5756
+ extractAudioMetadata as extractAudioMetadata2,
5757
+ runFfmpeg as runFfmpeg4
5758
+ } from "@hyperframes/engine";
5759
+ var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
5760
+ function checkedDbfs(value) {
5761
+ if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
5762
+ throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
5763
+ }
5764
+ return value;
5765
+ }
5766
+ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
5767
+ let firstBlock;
5768
+ let laterBlocks = Number.NEGATIVE_INFINITY;
5769
+ let partialLine = "";
5770
+ const readLines = (chunk) => {
5771
+ const lines = (partialLine + chunk).split(/\r|\n/);
5772
+ partialLine = lines.pop() ?? "";
5773
+ for (const line of lines) {
5774
+ const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
5775
+ if (!ftpk) continue;
5776
+ const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
5777
+ if (firstBlock === void 0) firstBlock = block;
5778
+ else laterBlocks = Math.max(laterBlocks, block);
5779
+ }
5780
+ };
5781
+ const result = await runFfmpeg4(
5782
+ ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
5783
+ { signal, timeout: timeoutMs, onStderr: readLines }
5784
+ );
5785
+ if (!result.success) {
5786
+ throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
5787
+ }
5788
+ const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
5789
+ return {
5790
+ peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
5791
+ firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
5792
+ laterBlocks: checkedDbfs(laterBlocks)
5793
+ };
5794
+ }
5795
+ async function measureTruePeakDbfs(audioPath, options) {
5796
+ const { timeoutMs, signal } = options;
5797
+ const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
5798
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
5799
+ const block = 4800;
5800
+ const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
5801
+ if (count === 1) return wholeFile();
5802
+ const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
5803
+ const peers = new AbortController();
5804
+ const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
5805
+ const scans = await Promise.all(
5806
+ Array.from({ length: count }, (_, k) => {
5807
+ const start = k === 0 ? 0 : cut(k) - block;
5808
+ const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
5809
+ return scanTruePeak(
5810
+ audioPath,
5811
+ `atrim=start_sample=${start}${end},`,
5812
+ timeoutMs,
5813
+ segmentSignal
5814
+ );
5815
+ })
5816
+ ).catch((error) => {
5817
+ peers.abort();
5818
+ throw error;
5819
+ });
5820
+ const last = scans[count - 1];
5821
+ const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
5822
+ if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
5823
+ return wholeFile();
5824
+ }
5825
+
5826
+ // src/services/render/audioPadTrim.ts
5720
5827
  var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
5721
5828
  var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
5722
5829
  var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
5723
5830
  var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
5724
- function buildAacTruePeakCorrectionArgs(inputPath, outputPath, targetDurationSeconds, attenuationDb) {
5725
- return [
5726
- "-i",
5727
- inputPath,
5728
- "-map",
5729
- "0:a:0",
5730
- "-vn",
5731
- "-af",
5732
- `volume=${attenuationDb.toFixed(3)}dB`,
5733
- "-t",
5734
- formatSeconds(targetDurationSeconds),
5735
- "-c:a",
5736
- "aac",
5737
- "-b:a",
5738
- "192k",
5739
- "-y",
5740
- outputPath
5741
- ];
5831
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
5832
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
5742
5833
  }
5743
5834
  function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
5744
5835
  const delta = targetDurationSeconds - sourceDurationSeconds;
@@ -5753,53 +5844,13 @@ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, tar
5753
5844
  if (delta > 0) {
5754
5845
  return {
5755
5846
  operation: "pad",
5756
- steps: [
5757
- {
5758
- kind: "normalize",
5759
- args: [
5760
- "-i",
5761
- audioPath,
5762
- "-af",
5763
- `apad,atrim=0:${targetSec}`,
5764
- "-t",
5765
- targetSec,
5766
- "-c:a",
5767
- "aac",
5768
- "-b:a",
5769
- "192k",
5770
- "-y",
5771
- outputPath
5772
- ]
5773
- }
5774
- ],
5847
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5775
5848
  cleanupPaths: []
5776
5849
  };
5777
5850
  }
5778
5851
  return {
5779
5852
  operation: "trim",
5780
- steps: [
5781
- {
5782
- kind: "trim",
5783
- args: [
5784
- "-i",
5785
- audioPath,
5786
- "-af",
5787
- `atrim=duration=${targetSec},asetpts=PTS-STARTPTS`,
5788
- // `atrim` limits decoded samples but does not cap muxer timestamps
5789
- // introduced by encoder delay/flush. The output duration contract
5790
- // is enforced at the container boundary as well; without `-t`, a
5791
- // long primed source can retain its original tail after re-encode.
5792
- "-t",
5793
- targetSec,
5794
- "-c:a",
5795
- "aac",
5796
- "-b:a",
5797
- "192k",
5798
- "-y",
5799
- outputPath
5800
- ]
5801
- }
5802
- ],
5853
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5803
5854
  cleanupPaths: []
5804
5855
  };
5805
5856
  }
@@ -5816,7 +5867,8 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5816
5867
  const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
5817
5868
  const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5818
5869
  const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
5819
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg ? void 0 : (audioPath, signal) => defaultProbeAudioTruePeakDbfs(audioPath, timeoutMs, signal));
5870
+ const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
5871
+ const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
5820
5872
  const [videoResult, audioResult] = await Promise.allSettled([
5821
5873
  probeVideo(input.videoPath),
5822
5874
  probeAudio(input.audioPath, input.signal)
@@ -5863,7 +5915,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5863
5915
  );
5864
5916
  try {
5865
5917
  for (const step of plan2.steps) {
5866
- const ffmpegResult = await runner(step.args);
5918
+ const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
5867
5919
  if (!ffmpegResult.success) {
5868
5920
  return {
5869
5921
  success: false,
@@ -5895,7 +5947,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5895
5947
  targetDurationSeconds,
5896
5948
  signal: input.signal,
5897
5949
  probeTruePeak,
5898
- runner
5950
+ encode
5899
5951
  });
5900
5952
  if (!correction.success) {
5901
5953
  return {
@@ -5925,8 +5977,8 @@ async function constrainAacTruePeak(input) {
5925
5977
  audioPath: input.audioPath,
5926
5978
  targetDurationSeconds: input.targetDurationSeconds,
5927
5979
  signal: input.signal,
5928
- probeTruePeak: (audioPath, signal) => defaultProbeAudioTruePeakDbfs(audioPath, timeoutMs, signal),
5929
- runner: (args) => defaultRunFfmpeg(args, timeoutMs, input.signal)
5980
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
5981
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
5930
5982
  });
5931
5983
  }
5932
5984
  async function enforceAacTruePeak(input) {
@@ -5953,13 +6005,11 @@ async function enforceAacTruePeak(input) {
5953
6005
  }
5954
6006
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
5955
6007
  attenuationDb += correctionTargetDbfs - truePeakDbfs;
5956
- const result = await input.runner(
5957
- buildAacTruePeakCorrectionArgs(
5958
- input.audioPath,
5959
- correctedPath,
5960
- input.targetDurationSeconds,
5961
- attenuationDb
5962
- )
6008
+ const result = await input.encode(
6009
+ input.audioPath,
6010
+ correctedPath,
6011
+ input.targetDurationSeconds,
6012
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
5963
6013
  );
5964
6014
  if (!result.success) {
5965
6015
  return {
@@ -6054,7 +6104,7 @@ function parseFrameRate(rate) {
6054
6104
  return { fpsNum, fpsDen };
6055
6105
  }
6056
6106
  async function defaultProbeAudioInfo(audioPath, signal) {
6057
- const metadata = await extractAudioMetadata2(audioPath, { signal });
6107
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
6058
6108
  return {
6059
6109
  durationSeconds: metadata.durationSeconds,
6060
6110
  sampleRate: metadata.sampleRate,
@@ -6062,28 +6112,23 @@ async function defaultProbeAudioInfo(audioPath, signal) {
6062
6112
  audioCodec: metadata.audioCodec
6063
6113
  };
6064
6114
  }
6065
- async function defaultProbeAudioTruePeakDbfs(audioPath, timeoutMs, signal) {
6066
- const result = await runFfmpeg4(
6067
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", "ebur128=peak=true", "-f", "null", "-"],
6068
- { signal, timeout: timeoutMs }
6069
- );
6070
- if (!result.success) {
6071
- throw new Error(`[audioPadTrim] ${describeFfmpegFailure(result, timeoutMs)}`);
6072
- }
6073
- const matches = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
6074
- const token = matches.at(-1)?.[1]?.toLowerCase();
6075
- const value = token === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
6076
- if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
6077
- throw new Error("[audioPadTrim] FFmpeg did not report AAC true peak");
6078
- }
6079
- return value;
6080
- }
6081
6115
  async function defaultRunFfmpeg(args, timeoutMs, signal) {
6082
- const result = await runFfmpeg4(args, { signal, timeout: timeoutMs });
6116
+ return toStepResult(await runFfmpeg5(args, { signal, timeout: timeoutMs }), timeoutMs);
6117
+ }
6118
+ function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
6119
+ return async (inputPath, outputPath, durationSeconds, filter) => {
6120
+ const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
6121
+ return toStepResult(
6122
+ await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
6123
+ timeoutMs
6124
+ );
6125
+ };
6126
+ }
6127
+ function toStepResult(result, timeoutMs) {
6083
6128
  if (result.success) return { success: true };
6084
6129
  return {
6085
6130
  success: false,
6086
- error: `[audioPadTrim] ${describeFfmpegFailure(result, timeoutMs)}`,
6131
+ error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
6087
6132
  failureReason: result.failureReason
6088
6133
  };
6089
6134
  }
@@ -6455,15 +6500,33 @@ function createRenderJob(config) {
6455
6500
  warnings: []
6456
6501
  };
6457
6502
  }
6503
+ function explainStreamingEncodeGate(cfg, outputFormat, workerCount, durationSeconds, forceParallelStream = false) {
6504
+ if (!cfg.enableStreamingEncode) return { enabled: false, reason: "disabled_by_config" };
6505
+ if (outputFormat === "png-sequence" || outputFormat === "gif") {
6506
+ return { enabled: false, reason: "format_excluded" };
6507
+ }
6508
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
6509
+ return { enabled: false, reason: "invalid_duration" };
6510
+ }
6511
+ const overCap = cfg.streamingEncodeDurationCapEnabled === true && durationSeconds > cfg.streamingEncodeMaxDurationSeconds;
6512
+ if (overCap && !cfg.lowMemoryMode) return { enabled: false, reason: "duration_cap" };
6513
+ if (forceParallelStream || process.env.HF_DE_PARALLEL_STREAM === "true") {
6514
+ return { enabled: true, reason: "parallel_forced" };
6515
+ }
6516
+ if (workerCount === 1) {
6517
+ return { enabled: true, reason: overCap ? "low_memory_mode" : "single_worker" };
6518
+ }
6519
+ return { enabled: false, reason: "multi_worker" };
6520
+ }
6458
6521
  var DEFAULT_SEGMENTED_MIN_SECONDS = Number.POSITIVE_INFINITY;
6459
6522
 
6460
6523
  // src/services/distributed/planFrameCost.ts
6461
6524
  import { join as join17 } from "node:path";
6462
6525
  import {
6463
6526
  closeCaptureSession as closeCaptureSession9,
6464
- createCaptureSession as createCaptureSession8,
6527
+ createCaptureSession as createCaptureSession9,
6465
6528
  createFrameLookupTable as createFrameLookupTable3,
6466
- initializeSession as initializeSession8
6529
+ initializeSession as initializeSession9
6467
6530
  } from "@hyperframes/engine";
6468
6531
 
6469
6532
  // src/services/distributed/chunkFrames.ts
@@ -6861,6 +6924,29 @@ function buildChunkCaptureOptions(input) {
6861
6924
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
6862
6925
  };
6863
6926
  }
6927
+ function shownDeferredFrameIndexes(planVideos, frames, fps) {
6928
+ const shown = /* @__PURE__ */ new Map();
6929
+ const deferred = planVideos.extracted.filter((video) => video.deferredRange);
6930
+ if (deferred.length === 0) return shown;
6931
+ const table = createFrameLookupTable2(
6932
+ planVideos.videos,
6933
+ deferred.map((video) => ({
6934
+ ...video,
6935
+ outputDir: "",
6936
+ framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)]))
6937
+ })),
6938
+ resolveRenderFpsConfig(fps).value
6939
+ );
6940
+ for (const frame of frames) {
6941
+ const globalTime = frame * fps.den / fps.num;
6942
+ for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
6943
+ const indexes = shown.get(videoId) ?? /* @__PURE__ */ new Set();
6944
+ indexes.add(payload.frameIndex);
6945
+ shown.set(videoId, indexes);
6946
+ }
6947
+ }
6948
+ return shown;
6949
+ }
6864
6950
  async function extractDeferredVideoFramesForChunk(input) {
6865
6951
  const deferred = input.planVideos.extracted.filter((video) => video.deferredRange);
6866
6952
  const remoteSources = input.remoteSources ?? {};
@@ -6877,24 +6963,7 @@ async function extractDeferredVideoFramesForChunk(input) {
6877
6963
  });
6878
6964
  }
6879
6965
  if (deferred.length === 0) return [];
6880
- const table = createFrameLookupTable2(
6881
- input.planVideos.videos,
6882
- deferred.map((video) => ({
6883
- ...video,
6884
- outputDir: input.outputRoot,
6885
- framePaths: new Map(Array.from({ length: video.totalFrames }, (_, i) => [i, String(i)]))
6886
- })),
6887
- resolveRenderFpsConfig(input.fps).value
6888
- );
6889
- const shown = /* @__PURE__ */ new Map();
6890
- for (const frame of input.frames) {
6891
- const globalTime = frame * input.fps.den / input.fps.num;
6892
- for (const [videoId, payload] of table.getActiveFramePayloads(globalTime)) {
6893
- const indexes = shown.get(videoId) ?? /* @__PURE__ */ new Set();
6894
- indexes.add(payload.frameIndex);
6895
- shown.set(videoId, indexes);
6896
- }
6897
- }
6966
+ const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
6898
6967
  return Promise.all(
6899
6968
  deferred.map(async (video) => {
6900
6969
  const outputDir = join16(input.outputRoot, video.videoId);
@@ -6920,7 +6989,8 @@ async function extractDeferredVideoFramesForChunk(input) {
6920
6989
  fps: input.fps,
6921
6990
  outputDir: input.outputRoot,
6922
6991
  format: range.format,
6923
- frameRanges: contiguousRuns(indexes)
6992
+ frameRanges: contiguousRuns(indexes),
6993
+ shownBox: input.shownBoxes?.get(video.videoId)
6924
6994
  },
6925
6995
  void 0,
6926
6996
  input.cfg
@@ -6967,7 +7037,7 @@ async function measurePlanSecondsPerFrame(input) {
6967
7037
  });
6968
7038
  let session = null;
6969
7039
  try {
6970
- session = await createCaptureSession8(
7040
+ session = await createCaptureSession9(
6971
7041
  fileServer.url,
6972
7042
  join17(workDir, "frame-cost"),
6973
7043
  buildChunkCaptureOptions({
@@ -6979,7 +7049,7 @@ async function measurePlanSecondsPerFrame(input) {
6979
7049
  createChunkVideoFrameInjectorFactory(lookup)(),
6980
7050
  createCaptureCalibrationConfig(input.cfg)
6981
7051
  );
6982
- await initializeSession8(session);
7052
+ await initializeSession9(session);
6983
7053
  const { estimate } = await measureCaptureCostFromSession(session, input.totalFrames, fps, log);
6984
7054
  return (estimate.p95Ms ?? 0) / 1e3;
6985
7055
  } finally {
@@ -7375,7 +7445,7 @@ function applyRuntimeEnvSnapshot(snapshot, env = process.env) {
7375
7445
  import { closeSync as closeSync4, existsSync as existsSync14, ftruncateSync, mkdirSync as mkdirSync9, openSync as openSync4, readFileSync as readFileSync9 } from "node:fs";
7376
7446
  import { writeFileSync as writeFileSync5, writeSync } from "node:fs";
7377
7447
  import { join as join20 } from "node:path";
7378
- import { appendRenderProvenanceArgs, describeFfmpegFailure as describeFfmpegFailure2, runFfmpeg as runFfmpeg5 } from "@hyperframes/engine";
7448
+ import { appendRenderProvenanceArgs, describeFfmpegFailure as describeFfmpegFailure3, runFfmpeg as runFfmpeg6 } from "@hyperframes/engine";
7379
7449
 
7380
7450
  // src/services/distributed/mp4Stitch.ts
7381
7451
  import {
@@ -7573,6 +7643,20 @@ function withDuration(buf, target, duration) {
7573
7643
  function timescaleOf(buf, header) {
7574
7644
  return buf.readUInt32BE(header.bodyStart + (buf[header.bodyStart] === 1 ? 20 : 12));
7575
7645
  }
7646
+ var TRACK_ID_AT = { tkhd: [12, 20], mvhd: [96, 108] };
7647
+ function idOf(buf, target) {
7648
+ return buf.readUInt32BE(
7649
+ target.bodyStart + TRACK_ID_AT[target.type][buf[target.bodyStart] === 1 ? 1 : 0]
7650
+ );
7651
+ }
7652
+ function withId(boxBytes, type, id) {
7653
+ const header = boxAt(boxBytes, 0, 0, boxBytes.length);
7654
+ boxBytes.writeUInt32BE(
7655
+ id,
7656
+ header.bodyStart + TRACK_ID_AT[type][boxBytes[header.bodyStart] === 1 ? 1 : 0]
7657
+ );
7658
+ return boxBytes;
7659
+ }
7576
7660
  function buildStbl(stsd, track) {
7577
7661
  const stts = runs(track.durations);
7578
7662
  const stsc = [];
@@ -7614,24 +7698,48 @@ function planTracksOf(tracks, plan2, index) {
7614
7698
  }
7615
7699
  return matched;
7616
7700
  }
7617
- function buildStitchedHeader(layouts) {
7618
- const plan2 = readTracks(layouts[0].moov);
7619
- const chunks = layouts.map((layout, i) => planTracksOf(readTracks(layout.moov), plan2, i));
7701
+ function sourceTracks(layouts, audio) {
7702
+ const chunkPlan = readTracks(layouts[0].moov);
7703
+ const audioTracks = audio ? readTracks(audio.moov) : [];
7704
+ if (audio && (audioTracks.length !== 1 || audioTracks[0].handler !== "soun")) {
7705
+ throw new Error("[mp4Stitch] the separate audio file must hold one audio track");
7706
+ }
7707
+ const plan2 = [...chunkPlan, ...audioTracks];
7708
+ const chunks = layouts.map((layout, i) => {
7709
+ const tracks = readTracks(layout.moov);
7710
+ if (audio && tracks.some((track) => track.handler === "soun")) {
7711
+ throw new Error(`[mp4Stitch] chunk ${i} carries its own audio`);
7712
+ }
7713
+ return planTracksOf(tracks, plan2, i);
7714
+ });
7715
+ return {
7716
+ plan: plan2,
7717
+ chunkTrackCount: chunkPlan.length,
7718
+ sources: audio ? [[...chunkPlan.map(() => null), audioTracks[0]], ...chunks] : chunks
7719
+ };
7720
+ }
7721
+ function buildStitchedHeader(layouts, audio) {
7722
+ const { plan: plan2, chunkTrackCount, sources } = sourceTracks(layouts, audio);
7723
+ const sourceLayouts = audio ? [audio, ...layouts] : layouts;
7620
7724
  const payloadBases = [];
7621
7725
  let payloadBytes = 0;
7622
- for (const layout of layouts) {
7726
+ for (const layout of sourceLayouts) {
7623
7727
  payloadBases.push(payloadBytes);
7624
7728
  payloadBytes += layout.payloadEnd - layout.payloadStart;
7625
7729
  }
7626
7730
  const template = layouts[0].moov;
7627
7731
  const root = boxAt(template, 0, 0, template.length);
7628
- const movieTimescale = timescaleOf(template, child(template, root, "mvhd"));
7732
+ const mvhd = child(template, root, "mvhd");
7733
+ const movieTimescale = timescaleOf(template, mvhd);
7734
+ const chunkTrackIds = plan2.slice(0, chunkTrackCount).map((track) => idOf(template, child(template, track.trak, "tkhd")));
7735
+ const audioTrackId = Math.max(...chunkTrackIds) + 1;
7629
7736
  const buildMoov = (dataStart2) => {
7630
7737
  const traks = plan2.map((track, t) => {
7631
- const parts = chunks.flatMap((tracks, chunk) => {
7738
+ const parts = sources.flatMap((tracks, chunk) => {
7632
7739
  const part = tracks[t];
7633
7740
  return part ? [{ ...part, chunk }] : [];
7634
7741
  });
7742
+ const moov = t < chunkTrackCount ? template : audio.moov;
7635
7743
  const merged = {
7636
7744
  durations: parts.flatMap((part) => part.durations),
7637
7745
  sizes: parts.flatMap((part) => part.sizes),
@@ -7640,9 +7748,9 @@ function buildStitchedHeader(layouts) {
7640
7748
  chunkOffsets: parts.flatMap(
7641
7749
  (part) => part.chunkOffsets.map((offset) => {
7642
7750
  const i = part.chunk;
7643
- const { payloadStart, payloadEnd } = layouts[i];
7751
+ const { payloadStart, payloadEnd } = sourceLayouts[i];
7644
7752
  if (offset < payloadStart || offset >= payloadEnd) {
7645
- throw new Error(`[mp4Stitch] chunk ${i} has samples outside its mdat`);
7753
+ throw new Error(`[mp4Stitch] source ${i} has samples outside its mdat`);
7646
7754
  }
7647
7755
  return offset - payloadStart + payloadBases[i] + dataStart2;
7648
7756
  })
@@ -7658,20 +7766,25 @@ function buildStitchedHeader(layouts) {
7658
7766
  elst.writeBigInt64BE(BigInt(track.mediaTime), 12);
7659
7767
  elst.writeUInt32BE(65536, 20);
7660
7768
  const edts = track.mediaTime ? box("edts", fullBox("elst", 1, elst)) : Buffer.alloc(0);
7661
- const trak = rebuild(template, track.trak, {
7662
- tkhd: (tkhd) => Buffer.concat([withDuration(template, tkhd, shown), edts]),
7769
+ const tkhdOf = (tkhd) => {
7770
+ const timed = withDuration(moov, tkhd, shown);
7771
+ return t < chunkTrackCount ? timed : withId(timed, "tkhd", audioTrackId);
7772
+ };
7773
+ const trak = rebuild(moov, track.trak, {
7774
+ tkhd: (tkhd) => Buffer.concat([tkhdOf(tkhd), edts]),
7663
7775
  edts: () => Buffer.alloc(0),
7664
- mdia: (mdia) => rebuild(template, mdia, {
7665
- mdhd: (mdhd) => withDuration(template, mdhd, mediaDuration),
7666
- minf: (minf) => rebuild(template, minf, { stbl: () => buildStbl(track.stsd, merged) })
7776
+ mdia: (mdia) => rebuild(moov, mdia, {
7777
+ mdhd: (mdhd) => withDuration(moov, mdhd, mediaDuration),
7778
+ minf: (minf) => rebuild(moov, minf, { stbl: () => buildStbl(track.stsd, merged) })
7667
7779
  })
7668
7780
  });
7669
7781
  return { trak, shown };
7670
7782
  });
7671
7783
  let next = 0;
7784
+ const movieHeader = withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown)));
7672
7785
  return rebuild(template, root, {
7673
- mvhd: (mvhd) => withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown))),
7674
- trak: () => traks[next++].trak
7786
+ mvhd: () => audio ? withId(movieHeader, "mvhd", audioTrackId + 1) : movieHeader,
7787
+ trak: () => ++next < chunkTrackCount ? traks[next - 1].trak : Buffer.concat(traks.slice(next - 1).map((t) => t.trak))
7675
7788
  });
7676
7789
  };
7677
7790
  const ftyp = layouts[0].ftyp;
@@ -7704,15 +7817,16 @@ async function copyPayload(path, layout, index, out) {
7704
7817
  );
7705
7818
  }
7706
7819
  }
7707
- async function writeStitchedMp4(chunkPaths, outputPath) {
7708
- const layouts = await Promise.all(chunkPaths.map((path) => readMp4Layout(fileRange(path))));
7709
- const header = buildStitchedHeader(layouts);
7820
+ async function writeStitchedMp4(chunkPaths, outputPath, audioPath) {
7821
+ const sourcePaths = audioPath ? [audioPath, ...chunkPaths] : chunkPaths;
7822
+ const layouts = await Promise.all(sourcePaths.map((path) => readMp4Layout(fileRange(path))));
7823
+ const header = audioPath ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
7710
7824
  mkdirSync8(dirname9(outputPath), { recursive: true });
7711
7825
  const workDir = mkdtempSync3(`${outputPath}.stitch-`);
7712
7826
  const out = createWriteStream(join19(workDir, "out.mp4"));
7713
7827
  try {
7714
7828
  out.write(header);
7715
- for (const [i, path] of chunkPaths.entries()) await copyPayload(path, layouts[i], i, out);
7829
+ for (const [i, path] of sourcePaths.entries()) await copyPayload(path, layouts[i], i, out);
7716
7830
  out.end();
7717
7831
  await finished(out);
7718
7832
  renameSync3(join19(workDir, "out.mp4"), outputPath);
@@ -7865,10 +7979,10 @@ async function muxChunkWithAudio(input) {
7865
7979
  const args = [...chunkMuxArgs(input, cut), "-c", "copy"];
7866
7980
  appendRenderProvenanceArgs(args, input.outputPath);
7867
7981
  args.push("-y", input.outputPath);
7868
- const result = await runFfmpeg5(args, { signal: input.signal, timeout: input.timeoutMs });
7982
+ const result = await runFfmpeg6(args, { signal: input.signal, timeout: input.timeoutMs });
7869
7983
  if (!result.success) {
7870
7984
  throw new Error(
7871
- `[chunkAudio] chunk mux failed: ${describeFfmpegFailure2(result, input.timeoutMs)}`
7985
+ `[chunkAudio] chunk mux failed: ${describeFfmpegFailure3(result, input.timeoutMs)}`
7872
7986
  );
7873
7987
  }
7874
7988
  const slice = input.chunks[input.chunkIndex];
@@ -8287,7 +8401,8 @@ function buildLockedRenderConfig(input) {
8287
8401
  chunkCount: input.chunkCount,
8288
8402
  runtimeEnv: input.runtimeEnv,
8289
8403
  variables: config.variables,
8290
- ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {}
8404
+ ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
8405
+ ...input.separateAudio ? { separateAudio: true } : {}
8291
8406
  };
8292
8407
  }
8293
8408
  function carriesChunkAudio(config, options) {
@@ -8382,8 +8497,10 @@ function createVideoSourceShipper(input) {
8382
8497
  return result;
8383
8498
  };
8384
8499
  }
8385
- async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8386
- const planDir = executionPlanDir;
8500
+ function plansSeparateAudio(config, options) {
8501
+ return options.deferAudio === true && config.format === "mp4" && config.cfr !== true;
8502
+ }
8503
+ async function compilePlanComposition(projectDir, config, planDir, options) {
8387
8504
  rejectUnsupportedDistributedFormat(config);
8388
8505
  validateNoGpuEncode({
8389
8506
  useGpu: false,
@@ -8519,6 +8636,100 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8519
8636
  });
8520
8637
  }
8521
8638
  }
8639
+ return {
8640
+ projectDir,
8641
+ log,
8642
+ sizeLimitBytes,
8643
+ abortSignal,
8644
+ assertNotAborted,
8645
+ cfg,
8646
+ job,
8647
+ workDir,
8648
+ compiledDir,
8649
+ finalCompiledDir,
8650
+ remoteFiles,
8651
+ urlByAbsentLocalPath,
8652
+ localPathByUrl,
8653
+ compileResult,
8654
+ compiled,
8655
+ composition,
8656
+ deviceScaleFactor,
8657
+ forceScreenshot,
8658
+ width,
8659
+ height,
8660
+ probeResult,
8661
+ totalFrames
8662
+ };
8663
+ }
8664
+ function mixPlanAudio(c, compiledDir, seconds, pcmPath) {
8665
+ return runAudioStage({
8666
+ projectDir: c.projectDir,
8667
+ workDir: c.workDir,
8668
+ compiledDir,
8669
+ urlByAbsentLocalPath: c.urlByAbsentLocalPath,
8670
+ duration: seconds,
8671
+ ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
8672
+ audioGain: c.cfg.audioGain,
8673
+ audios: c.composition.audios,
8674
+ masterFxChain: c.composition.masterFxChain,
8675
+ ...pcmPath ? { keepEncoderInputAt: pcmPath } : {},
8676
+ abortSignal: c.abortSignal,
8677
+ assertNotAborted: c.assertNotAborted
8678
+ });
8679
+ }
8680
+ async function extractPlanVideos(c, deferRangeExtraction) {
8681
+ const extractResult = await runExtractVideosStage({
8682
+ projectDir: c.projectDir,
8683
+ compiledDir: c.compiledDir,
8684
+ job: c.job,
8685
+ cfg: c.cfg,
8686
+ composition: c.composition,
8687
+ abortSignal: c.abortSignal,
8688
+ assertNotAborted: c.assertNotAborted,
8689
+ materializeSymlinks: true,
8690
+ deferRangeExtraction,
8691
+ urlByAbsentLocalPath: c.urlByAbsentLocalPath
8692
+ });
8693
+ if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
8694
+ if (extractResult.extractionResult) {
8695
+ assertVideoExtractionSucceeded(extractResult.extractionResult);
8696
+ }
8697
+ return extractResult;
8698
+ }
8699
+ async function holdPlanAudioPeak(c, audioPath, pcmPath, seconds) {
8700
+ const peak = await constrainAacTruePeak({
8701
+ audioPath,
8702
+ ...existsSync15(pcmPath) ? { sourcePath: pcmPath } : {},
8703
+ targetDurationSeconds: seconds,
8704
+ signal: c.abortSignal,
8705
+ timeoutMs: c.cfg.ffmpegProcessTimeout
8706
+ }).finally(() => rmSync8(pcmPath, { force: true }));
8707
+ if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
8708
+ }
8709
+ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8710
+ const planDir = executionPlanDir;
8711
+ const c = await compilePlanComposition(projectDir, config, planDir, options);
8712
+ const {
8713
+ log,
8714
+ sizeLimitBytes,
8715
+ cfg,
8716
+ job,
8717
+ workDir,
8718
+ compiledDir,
8719
+ finalCompiledDir,
8720
+ remoteFiles,
8721
+ urlByAbsentLocalPath,
8722
+ localPathByUrl,
8723
+ compileResult,
8724
+ compiled,
8725
+ composition,
8726
+ deviceScaleFactor,
8727
+ forceScreenshot,
8728
+ width,
8729
+ height,
8730
+ probeResult,
8731
+ totalFrames
8732
+ } = c;
8522
8733
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
8523
8734
  let maxParallel = resolveMaxParallelChunks(config, totalFrames, megapixels, log);
8524
8735
  const measureFrameCost = chunkSizingUsesFrameCost(config) && planNeedsFrameCostMeasurement(compiled.html);
@@ -8529,24 +8740,9 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8529
8740
  observedAt: "pre-extract",
8530
8741
  log
8531
8742
  });
8532
- stageStarted = Date.now();
8533
- const extractResult = await runExtractVideosStage({
8534
- projectDir,
8535
- compiledDir,
8536
- job,
8537
- cfg,
8538
- composition,
8539
- abortSignal,
8540
- assertNotAborted,
8541
- materializeSymlinks: true,
8542
- deferRangeExtraction: options.deferVideoExtraction,
8543
- urlByAbsentLocalPath
8544
- });
8743
+ let stageStarted = Date.now();
8744
+ const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
8545
8745
  logPlanStage(log, "extract", stageStarted);
8546
- if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
8547
- if (extractResult.extractionResult) {
8548
- assertVideoExtractionSucceeded(extractResult.extractionResult);
8549
- }
8550
8746
  const stagedVideoFrames = join22(compiledDir, "__hyperframes_video_frames");
8551
8747
  const videoFramesDst = join22(planDir, "video-frames");
8552
8748
  if (existsSync15(videoFramesDst)) rmSync8(videoFramesDst, { recursive: true, force: true });
@@ -8602,23 +8798,18 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8602
8798
  "utf-8"
8603
8799
  );
8604
8800
  const digests = options.digests ?? /* @__PURE__ */ new Map();
8605
- const chunksCarryAudio2 = carriesChunkAudio(config, options);
8606
- const audioSeconds = chunksCarryAudio2 ? totalFrames / config.fps : probeResult.duration;
8801
+ const separateAudio = plansSeparateAudio(config, options);
8802
+ const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options);
8803
+ const audioSeconds = chunksCarryAudio ? totalFrames / config.fps : probeResult.duration;
8804
+ const planMixPcmPath = join22(workDir, "plan-mix.wav");
8607
8805
  stageStarted = Date.now();
8608
8806
  const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
8609
- runAudioStage({
8610
- projectDir,
8611
- workDir,
8612
- compiledDir: finalCompiledDir,
8613
- urlByAbsentLocalPath,
8614
- duration: audioSeconds,
8615
- ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
8616
- audioGain: cfg.audioGain,
8617
- audios: composition.audios,
8618
- masterFxChain: composition.masterFxChain,
8619
- abortSignal,
8620
- assertNotAborted
8621
- }).finally(() => logPlanStage(log, "audio", stageStarted)),
8807
+ separateAudio ? null : mixPlanAudio(
8808
+ c,
8809
+ finalCompiledDir,
8810
+ audioSeconds,
8811
+ chunksCarryAudio ? planMixPcmPath : void 0
8812
+ ).finally(() => logPlanStage(log, "audio", stageStarted)),
8622
8813
  (async () => {
8623
8814
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
8624
8815
  await prehashPlanFiles(dir, digests);
@@ -8668,22 +8859,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8668
8859
  );
8669
8860
  const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
8670
8861
  const audioResult = audioOutcome.value;
8671
- if (audioResult.audioError) {
8862
+ if (audioResult?.audioError) {
8672
8863
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
8673
8864
  }
8674
8865
  const planAudioPath = join22(planDir, PLAN_AUDIO_RELATIVE_PATH);
8675
- if (audioResult.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8866
+ if (audioResult?.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8676
8867
  renameSync4(audioResult.audioOutputPath, planAudioPath);
8677
8868
  }
8678
- if (chunksCarryAudio2 && existsSync15(planAudioPath)) {
8869
+ if (chunksCarryAudio && existsSync15(planAudioPath)) {
8679
8870
  stageStarted = Date.now();
8680
- const peak = await constrainAacTruePeak({
8681
- audioPath: planAudioPath,
8682
- targetDurationSeconds: audioSeconds,
8683
- signal: abortSignal,
8684
- timeoutMs: cfg.ffmpegProcessTimeout
8685
- });
8686
- if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
8871
+ await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
8687
8872
  logPlanStage(log, "audio-peak", stageStarted);
8688
8873
  if (options.sliceChunkAudio) {
8689
8874
  stageStarted = Date.now();
@@ -8709,7 +8894,8 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8709
8894
  effectiveChunkSize,
8710
8895
  chunkCount,
8711
8896
  runtimeEnv,
8712
- chunksCarryAudio: chunksCarryAudio2
8897
+ chunksCarryAudio,
8898
+ separateAudio
8713
8899
  });
8714
8900
  const compositionJson = {
8715
8901
  durationSeconds: job.duration ?? 0,
@@ -8754,7 +8940,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8754
8940
  fontSnapshotSha,
8755
8941
  durationSeconds: job.duration ?? 0,
8756
8942
  totalFrames,
8757
- hasAudio: audioResult.hasAudio,
8943
+ hasAudio: audioResult?.hasAudio ?? false,
8758
8944
  digests
8759
8945
  });
8760
8946
  logPlanStage(log, "freeze", stageStarted);
@@ -8779,6 +8965,33 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8779
8965
  producerVersion
8780
8966
  };
8781
8967
  }
8968
+ async function buildPlanAudio(projectDir, config, workRoot, outputPath, options = {}) {
8969
+ const c = await compilePlanComposition(projectDir, config, workRoot, {
8970
+ ...options,
8971
+ executionPlanSizeLimitBytes: Number.MAX_SAFE_INTEGER
8972
+ });
8973
+ try {
8974
+ const seconds = c.totalFrames / config.fps;
8975
+ const pcmPath = join22(c.workDir, "plan-mix.wav");
8976
+ let stageStarted = Date.now();
8977
+ await extractPlanVideos(c, true);
8978
+ logPlanStage(c.log, "extract", stageStarted);
8979
+ stageStarted = Date.now();
8980
+ const mixed = await mixPlanAudio(c, c.compiledDir, seconds, pcmPath);
8981
+ logPlanStage(c.log, "audio", stageStarted);
8982
+ if (mixed.audioError) {
8983
+ applyDistributedAudioWarningPolicy(c.job, mixed.audioError, mixed.audioFailures, c.log);
8984
+ }
8985
+ if (!mixed.hasAudio || !existsSync15(mixed.audioOutputPath)) return { hasAudio: false };
8986
+ renameSync4(mixed.audioOutputPath, outputPath);
8987
+ stageStarted = Date.now();
8988
+ await holdPlanAudioPeak(c, outputPath, pcmPath, seconds);
8989
+ logPlanStage(c.log, "audio-peak", stageStarted);
8990
+ return { hasAudio: true };
8991
+ } finally {
8992
+ rmSync8(c.workDir, { recursive: true, force: true });
8993
+ }
8994
+ }
8782
8995
  async function plan(projectDir, config, planDir, options = {}) {
8783
8996
  const executionPlan = await buildLocalExecutionPlan(projectDir, config, planDir, options);
8784
8997
  return {
@@ -9387,7 +9600,8 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9387
9600
  )
9388
9601
  ),
9389
9602
  chunksCarryAudio: options.chunksCarryAudio,
9390
- sliceChunkAudio: true
9603
+ sliceChunkAudio: true,
9604
+ deferAudio: options.deferAudio
9391
9605
  });
9392
9606
  return await publishExecutionPlan(stagingRoot, publisher, { digests, log: config.logger });
9393
9607
  } catch (error) {
@@ -9698,12 +9912,12 @@ import {
9698
9912
  import { dirname as dirname12, join as join26 } from "node:path";
9699
9913
  import {
9700
9914
  appendRenderProvenanceArgs as appendRenderProvenanceArgs2,
9701
- describeFfmpegFailure as describeFfmpegFailure3,
9915
+ describeFfmpegFailure as describeFfmpegFailure4,
9702
9916
  getEncoderPreset as getEncoderPreset5,
9703
9917
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME4,
9704
9918
  muxVideoWithAudio as muxVideoWithAudio2,
9705
9919
  resolveConfig as resolveConfig4,
9706
- runFfmpeg as runFfmpeg6
9920
+ runFfmpeg as runFfmpeg7
9707
9921
  } from "@hyperframes/engine";
9708
9922
  import { fpsToFfmpegArg as fpsToFfmpegArg3 } from "@hyperframes/core";
9709
9923
  function cfrRateControlArgs(locked) {
@@ -9723,7 +9937,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
9723
9937
  return mergePngFrameDirs(chunkPaths, outputPath, plan2.totalFrames, audioPath, start);
9724
9938
  }
9725
9939
  if (chunksAreStitchedMp4(planDir, cfr)) {
9726
- await writeStitchedMp4(chunkPaths, outputPath);
9940
+ const separateAudio = stitchesSeparateAudio(planDir) && audioPath !== null;
9941
+ await writeStitchedMp4(chunkPaths, outputPath, separateAudio ? audioPath : void 0);
9727
9942
  return {
9728
9943
  outputPath,
9729
9944
  durationMs: Date.now() - start,
@@ -9912,25 +10127,29 @@ async function probeJoinedFrameInfo(videoPaths, fps, signal) {
9912
10127
  };
9913
10128
  }
9914
10129
  async function runAssembleFfmpeg(args, step, signal, timeout) {
9915
- const result = await runFfmpeg6(args, { signal, timeout });
10130
+ const result = await runFfmpeg7(args, { signal, timeout });
9916
10131
  if (!result.success) {
9917
10132
  throw encoderFailureError(`[assemble] ffmpeg ${step} failed`, {
9918
- error: describeFfmpegFailure3(result, timeout),
10133
+ error: describeFfmpegFailure4(result, timeout),
9919
10134
  failureReason: result.failureReason
9920
10135
  });
9921
10136
  }
9922
10137
  }
9923
10138
  function chunksAreStitchedMp4(planDir, cfr) {
9924
- if (!chunksCarryAudio(planDir)) return false;
10139
+ const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
10140
+ if (!chunksCarryAudio && !separateAudio) return false;
9925
10141
  if (cfr) {
9926
- throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks carry audio");
10142
+ throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks are stitched");
9927
10143
  }
9928
10144
  return true;
9929
10145
  }
9930
- function chunksCarryAudio(planDir) {
10146
+ function stitchesSeparateAudio(planDir) {
10147
+ return lockedAudio(planDir).separateAudio === true;
10148
+ }
10149
+ function lockedAudio(planDir) {
9931
10150
  const encoderJsonPath = join26(planDir, "meta", "encoder.json");
9932
- if (!existsSync18(encoderJsonPath)) return false;
9933
- return JSON.parse(readFileSync11(encoderJsonPath, "utf-8")).chunksCarryAudio === true;
10151
+ if (!existsSync18(encoderJsonPath)) return {};
10152
+ return JSON.parse(readFileSync11(encoderJsonPath, "utf-8"));
9934
10153
  }
9935
10154
  function readCfrEncoder(planDir, format) {
9936
10155
  if (format !== "mp4") {
@@ -10004,11 +10223,12 @@ import {
10004
10223
  calculateOptimalWorkers as calculateOptimalWorkers2,
10005
10224
  classifyCaptureFailure as classifyCaptureFailure4,
10006
10225
  closeCaptureSession as closeCaptureSession10,
10007
- createCaptureSession as createCaptureSession9,
10226
+ createCaptureSession as createCaptureSession10,
10008
10227
  createFrameLookupTable as createFrameLookupTable5,
10009
10228
  deriveBeginFrameProbeTimeTicks as deriveBeginFrameProbeTimeTicks2,
10010
10229
  getEncoderPreset as getEncoderPreset6,
10011
- initializeSession as initializeSession9,
10230
+ initializeSession as initializeSession10,
10231
+ measureVideoShownBoxes,
10012
10232
  probeBeginFrameLiveness as probeBeginFrameLiveness2,
10013
10233
  readWebGlVendorInfoFromCanvas,
10014
10234
  resolveConfig as resolveConfig5
@@ -10037,9 +10257,9 @@ function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
10037
10257
  return { startFrame, endFrame };
10038
10258
  }
10039
10259
  var distributedCaptureSessionDependencies = {
10040
- createCaptureSession: createCaptureSession9,
10260
+ createCaptureSession: createCaptureSession10,
10041
10261
  assertSwiftShader,
10042
- initializeSession: initializeSession9,
10262
+ initializeSession: initializeSession10,
10043
10263
  closeCaptureSession: closeCaptureSession10,
10044
10264
  readWebGlVendorInfo: readWebGlVendorInfoFromCanvas
10045
10265
  };
@@ -10230,34 +10450,15 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10230
10450
  const framesDir = join27(workDir, "captured-frames");
10231
10451
  mkdirSync14(framesDir, { recursive: true });
10232
10452
  const videoFramesDir = `${workDir}.video-frames`;
10233
- const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
10234
- planDir,
10235
- planVideos,
10236
- frames: Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i),
10237
- fps: { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen },
10238
- outputRoot: videoFramesDir,
10239
- cfg,
10240
- remoteSources
10241
- }) : [];
10453
+ const chunkFrameNumbers = Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i);
10454
+ const chunkFps = { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen };
10455
+ const deferredVideos = planVideos?.extracted.filter((video) => video.deferredRange) ?? [];
10456
+ const shownFrames = planVideos ? shownDeferredFrameIndexes(planVideos, chunkFrameNumbers, chunkFps) : /* @__PURE__ */ new Map();
10242
10457
  const remoteVideoIds = new Set(
10243
10458
  planVideos?.extracted.filter(
10244
10459
  (video) => video.deferredRange && Object.hasOwn(remoteSources, video.deferredRange.sourcePath)
10245
10460
  ).map((video) => video.videoId)
10246
10461
  );
10247
- const remoteVideos = deferredVideoFrames.filter((video) => remoteVideoIds.has(video.videoId));
10248
- const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
10249
- planVideos.videos,
10250
- [
10251
- ...rebuildExtractedFramesFromPlanDir(
10252
- planDir,
10253
- planVideos.extracted.filter((video) => !video.deferredRange),
10254
- v2Manifest === null ? "dense-v1" : "sparse-v2"
10255
- ),
10256
- ...deferredVideoFrames
10257
- ],
10258
- resolveRenderFpsConfig(job.config.fps).value
10259
- ) : null;
10260
- const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
10261
10462
  const fileServer = await createFileServer({
10262
10463
  projectDir: compiledDir,
10263
10464
  compiledDir,
@@ -10273,8 +10474,12 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10273
10474
  videoCount: planVideos?.videos.length ?? 0,
10274
10475
  html: readFileSync12(join27(compiledDir, "index.html"), "utf-8"),
10275
10476
  // The plan ships a deferred source only to chunks that show it; don't wait on the rest.
10276
- skipReadinessVideoIds: deferredVideoFrames.filter((video) => video.framePaths.size === 0 || remoteVideoIds.has(video.videoId)).map((video) => video.videoId),
10277
- videoMetadataHints: collectVideoMetadataHints(remoteVideos)
10477
+ skipReadinessVideoIds: deferredVideos.filter(
10478
+ (video) => !shownFrames.get(video.videoId)?.size || remoteVideoIds.has(video.videoId)
10479
+ ).map((video) => video.videoId),
10480
+ videoMetadataHints: collectVideoMetadataHints(
10481
+ deferredVideos.filter((video) => remoteVideoIds.has(video.videoId))
10482
+ )
10278
10483
  });
10279
10484
  const chunkWorkerCount = calculateOptimalWorkers2(framesInChunk, void 0, cfg);
10280
10485
  let session = null;
@@ -10287,6 +10492,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10287
10492
  const capturePerfs = [];
10288
10493
  const captureAttempts = [];
10289
10494
  let forceScreenshotForChunk = encoder.forceScreenshot;
10495
+ let shownBoxes;
10290
10496
  try {
10291
10497
  if (chunkWorkerCount === 1 || !forceScreenshotForChunk) {
10292
10498
  const bootStarted = Date.now();
@@ -10297,6 +10503,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10297
10503
  cfg
10298
10504
  );
10299
10505
  sessionBootMs = Date.now() - bootStarted;
10506
+ shownBoxes = await measureVideoShownBoxes(
10507
+ session.page,
10508
+ [...shownFrames.keys()],
10509
+ chunkFrameNumbers.map((frame) => frame * chunkFps.den / chunkFps.num)
10510
+ );
10300
10511
  const browserSelectedScreenshot = session.launchCaptureMode === "screenshot";
10301
10512
  const beginFrameStalled = !browserSelectedScreenshot && await beginFrameSessionNeedsScreenshotFallback(session);
10302
10513
  if (browserSelectedScreenshot) {
@@ -10338,6 +10549,29 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10338
10549
  session = null;
10339
10550
  }
10340
10551
  }
10552
+ const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
10553
+ planDir,
10554
+ planVideos,
10555
+ frames: chunkFrameNumbers,
10556
+ fps: chunkFps,
10557
+ outputRoot: videoFramesDir,
10558
+ cfg,
10559
+ remoteSources,
10560
+ shownBoxes
10561
+ }) : [];
10562
+ const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
10563
+ planVideos.videos,
10564
+ [
10565
+ ...rebuildExtractedFramesFromPlanDir(
10566
+ planDir,
10567
+ planVideos.extracted.filter((video) => !video.deferredRange),
10568
+ v2Manifest === null ? "dense-v1" : "sparse-v2"
10569
+ ),
10570
+ ...deferredVideoFrames
10571
+ ],
10572
+ resolveRenderFpsConfig(job.config.fps).value
10573
+ ) : null;
10574
+ const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
10341
10575
  const captureStarted = Date.now();
10342
10576
  captureMode = await runCaptureWithScreenshotFallback({
10343
10577
  forceScreenshot: forceScreenshotForChunk,
@@ -10528,6 +10762,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10528
10762
  encodeStageMs,
10529
10763
  workers: chunkWorkerCount,
10530
10764
  captureMode,
10765
+ // Device-pixel box each video's frames were extracted to cover; a video not listed kept full size.
10766
+ videoShownBoxes: Object.fromEntries(shownBoxes ?? []),
10531
10767
  sha256,
10532
10768
  outputKind,
10533
10769
  producerVersion: plan2.producerVersion,
@@ -10839,10 +11075,11 @@ function validateIntDimension(field, value) {
10839
11075
  }
10840
11076
 
10841
11077
  // src/services/distributed/projectHash.ts
10842
- import { readdirSync as readdirSync10, readFileSync as readFileSync13, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
11078
+ import { closeSync as closeSync5, openSync as openSync5, readdirSync as readdirSync10, readSync as readSync3, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
10843
11079
  import { createHash as createHash6 } from "node:crypto";
10844
11080
  import { join as join29 } from "node:path";
10845
11081
  var SITE_ID_SCHEME = "site-v2\0";
11082
+ var READ_CHUNK_BYTES = 256 * 1024;
10846
11083
  function listSiteFiles(projectDir) {
10847
11084
  const files = [];
10848
11085
  const ancestors = /* @__PURE__ */ new Set([realpathSync2(projectDir)]);
@@ -10875,10 +11112,19 @@ function listSiteFiles(projectDir) {
10875
11112
  }
10876
11113
  function hashProjectDir(projectDir, files = listSiteFiles(projectDir)) {
10877
11114
  const hash = createHash6("sha256").update(SITE_ID_SCHEME);
11115
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
10878
11116
  for (const rel of files) {
10879
11117
  hash.update(rel);
10880
11118
  hash.update("\0");
10881
- hash.update(readFileSync13(join29(projectDir, rel)));
11119
+ const fd = openSync5(join29(projectDir, rel), "r");
11120
+ try {
11121
+ let read;
11122
+ while ((read = readSync3(fd, chunk, 0, chunk.length, null)) > 0) {
11123
+ hash.update(chunk.subarray(0, read));
11124
+ }
11125
+ } finally {
11126
+ closeSync5(fd);
11127
+ }
10882
11128
  }
10883
11129
  return hash.digest("hex").slice(0, 16);
10884
11130
  }
@@ -10926,6 +11172,7 @@ export {
10926
11172
  assemble,
10927
11173
  assembleV2,
10928
11174
  buildChunkSlices,
11175
+ buildPlanAudio,
10929
11176
  buildStitchedHeader,
10930
11177
  chunksAreStitchedMp4,
10931
11178
  createPlanV2FromExecutionPlan,
@@ -10943,6 +11190,7 @@ export {
10943
11190
  plan,
10944
11191
  planV2,
10945
11192
  planV2WithPublisher,
11193
+ plansSeparateAudio,
10946
11194
  publishPlanV2FromExecutionPlan,
10947
11195
  publishPlanV2FromV1,
10948
11196
  readMp4Layout,
@@ -10955,6 +11203,7 @@ export {
10955
11203
  renderChunkV2,
10956
11204
  resolveChunkPlan,
10957
11205
  resolvePlanAudioPath,
11206
+ stitchesSeparateAudio,
10958
11207
  validateDistributedRenderConfig,
10959
11208
  validatePlanV2MaterializedTarget,
10960
11209
  validateVariablesPayload