@hyperframes/producer 0.8.181 → 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,
@@ -5710,7 +5740,8 @@ import { dirname as dirname6, join as join13 } from "node:path";
5710
5740
  import {
5711
5741
  DEFAULT_CONFIG,
5712
5742
  describeFfmpegFailure as describeFfmpegFailure2,
5713
- extractAudioMetadata as extractAudioMetadata2,
5743
+ encodeAac,
5744
+ extractAudioMetadata as extractAudioMetadata3,
5714
5745
  getFfprobeBinary,
5715
5746
  ManagedChildProcess,
5716
5747
  runFfmpeg as runFfmpeg5,
@@ -5719,7 +5750,12 @@ import {
5719
5750
  import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
5720
5751
 
5721
5752
  // src/services/render/audioTruePeak.ts
5722
- import { aacSegmentCount, describeFfmpegFailure, runFfmpeg as runFfmpeg4 } from "@hyperframes/engine";
5753
+ import {
5754
+ aacSegmentCount,
5755
+ describeFfmpegFailure,
5756
+ extractAudioMetadata as extractAudioMetadata2,
5757
+ runFfmpeg as runFfmpeg4
5758
+ } from "@hyperframes/engine";
5723
5759
  var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
5724
5760
  function checkedDbfs(value) {
5725
5761
  if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
@@ -5757,12 +5793,13 @@ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
5757
5793
  };
5758
5794
  }
5759
5795
  async function measureTruePeakDbfs(audioPath, options) {
5760
- const { timeoutMs, signal, segments } = options;
5796
+ const { timeoutMs, signal } = options;
5761
5797
  const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
5798
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
5762
5799
  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;
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;
5766
5803
  const peers = new AbortController();
5767
5804
  const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
5768
5805
  const scans = await Promise.all(
@@ -5791,24 +5828,8 @@ var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
5791
5828
  var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
5792
5829
  var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
5793
5830
  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
- ];
5831
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
5832
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
5812
5833
  }
5813
5834
  function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
5814
5835
  const delta = targetDurationSeconds - sourceDurationSeconds;
@@ -5823,53 +5844,13 @@ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, tar
5823
5844
  if (delta > 0) {
5824
5845
  return {
5825
5846
  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
- ],
5847
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5845
5848
  cleanupPaths: []
5846
5849
  };
5847
5850
  }
5848
5851
  return {
5849
5852
  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
- ],
5853
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5873
5854
  cleanupPaths: []
5874
5855
  };
5875
5856
  }
@@ -5886,7 +5867,8 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5886
5867
  const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
5887
5868
  const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5888
5869
  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 }));
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 }));
5890
5872
  const [videoResult, audioResult] = await Promise.allSettled([
5891
5873
  probeVideo(input.videoPath),
5892
5874
  probeAudio(input.audioPath, input.signal)
@@ -5933,7 +5915,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5933
5915
  );
5934
5916
  try {
5935
5917
  for (const step of plan2.steps) {
5936
- 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);
5937
5919
  if (!ffmpegResult.success) {
5938
5920
  return {
5939
5921
  success: false,
@@ -5965,7 +5947,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
5965
5947
  targetDurationSeconds,
5966
5948
  signal: input.signal,
5967
5949
  probeTruePeak,
5968
- runner
5950
+ encode
5969
5951
  });
5970
5952
  if (!correction.success) {
5971
5953
  return {
@@ -5995,12 +5977,8 @@ async function constrainAacTruePeak(input) {
5995
5977
  audioPath: input.audioPath,
5996
5978
  targetDurationSeconds: input.targetDurationSeconds,
5997
5979
  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)
5980
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
5981
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
6004
5982
  });
6005
5983
  }
6006
5984
  async function enforceAacTruePeak(input) {
@@ -6027,13 +6005,11 @@ async function enforceAacTruePeak(input) {
6027
6005
  }
6028
6006
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
6029
6007
  attenuationDb += correctionTargetDbfs - truePeakDbfs;
6030
- const result = await input.runner(
6031
- buildAacTruePeakCorrectionArgs(
6032
- input.audioPath,
6033
- correctedPath,
6034
- input.targetDurationSeconds,
6035
- attenuationDb
6036
- )
6008
+ const result = await input.encode(
6009
+ input.audioPath,
6010
+ correctedPath,
6011
+ input.targetDurationSeconds,
6012
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
6037
6013
  );
6038
6014
  if (!result.success) {
6039
6015
  return {
@@ -6128,7 +6104,7 @@ function parseFrameRate(rate) {
6128
6104
  return { fpsNum, fpsDen };
6129
6105
  }
6130
6106
  async function defaultProbeAudioInfo(audioPath, signal) {
6131
- const metadata = await extractAudioMetadata2(audioPath, { signal });
6107
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
6132
6108
  return {
6133
6109
  durationSeconds: metadata.durationSeconds,
6134
6110
  sampleRate: metadata.sampleRate,
@@ -6137,7 +6113,18 @@ async function defaultProbeAudioInfo(audioPath, signal) {
6137
6113
  };
6138
6114
  }
6139
6115
  async function defaultRunFfmpeg(args, timeoutMs, signal) {
6140
- const result = await runFfmpeg5(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) {
6141
6128
  if (result.success) return { success: true };
6142
6129
  return {
6143
6130
  success: false,
@@ -6513,15 +6500,33 @@ function createRenderJob(config) {
6513
6500
  warnings: []
6514
6501
  };
6515
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
+ }
6516
6521
  var DEFAULT_SEGMENTED_MIN_SECONDS = Number.POSITIVE_INFINITY;
6517
6522
 
6518
6523
  // src/services/distributed/planFrameCost.ts
6519
6524
  import { join as join17 } from "node:path";
6520
6525
  import {
6521
6526
  closeCaptureSession as closeCaptureSession9,
6522
- createCaptureSession as createCaptureSession8,
6527
+ createCaptureSession as createCaptureSession9,
6523
6528
  createFrameLookupTable as createFrameLookupTable3,
6524
- initializeSession as initializeSession8
6529
+ initializeSession as initializeSession9
6525
6530
  } from "@hyperframes/engine";
6526
6531
 
6527
6532
  // src/services/distributed/chunkFrames.ts
@@ -6919,6 +6924,29 @@ function buildChunkCaptureOptions(input) {
6919
6924
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
6920
6925
  };
6921
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
+ }
6922
6950
  async function extractDeferredVideoFramesForChunk(input) {
6923
6951
  const deferred = input.planVideos.extracted.filter((video) => video.deferredRange);
6924
6952
  const remoteSources = input.remoteSources ?? {};
@@ -6935,24 +6963,7 @@ async function extractDeferredVideoFramesForChunk(input) {
6935
6963
  });
6936
6964
  }
6937
6965
  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
- }
6966
+ const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
6956
6967
  return Promise.all(
6957
6968
  deferred.map(async (video) => {
6958
6969
  const outputDir = join16(input.outputRoot, video.videoId);
@@ -6978,7 +6989,8 @@ async function extractDeferredVideoFramesForChunk(input) {
6978
6989
  fps: input.fps,
6979
6990
  outputDir: input.outputRoot,
6980
6991
  format: range.format,
6981
- frameRanges: contiguousRuns(indexes)
6992
+ frameRanges: contiguousRuns(indexes),
6993
+ shownBox: input.shownBoxes?.get(video.videoId)
6982
6994
  },
6983
6995
  void 0,
6984
6996
  input.cfg
@@ -7025,7 +7037,7 @@ async function measurePlanSecondsPerFrame(input) {
7025
7037
  });
7026
7038
  let session = null;
7027
7039
  try {
7028
- session = await createCaptureSession8(
7040
+ session = await createCaptureSession9(
7029
7041
  fileServer.url,
7030
7042
  join17(workDir, "frame-cost"),
7031
7043
  buildChunkCaptureOptions({
@@ -7037,7 +7049,7 @@ async function measurePlanSecondsPerFrame(input) {
7037
7049
  createChunkVideoFrameInjectorFactory(lookup)(),
7038
7050
  createCaptureCalibrationConfig(input.cfg)
7039
7051
  );
7040
- await initializeSession8(session);
7052
+ await initializeSession9(session);
7041
7053
  const { estimate } = await measureCaptureCostFromSession(session, input.totalFrames, fps, log);
7042
7054
  return (estimate.p95Ms ?? 0) / 1e3;
7043
7055
  } finally {
@@ -7631,6 +7643,20 @@ function withDuration(buf, target, duration) {
7631
7643
  function timescaleOf(buf, header) {
7632
7644
  return buf.readUInt32BE(header.bodyStart + (buf[header.bodyStart] === 1 ? 20 : 12));
7633
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
+ }
7634
7660
  function buildStbl(stsd, track) {
7635
7661
  const stts = runs(track.durations);
7636
7662
  const stsc = [];
@@ -7672,24 +7698,48 @@ function planTracksOf(tracks, plan2, index) {
7672
7698
  }
7673
7699
  return matched;
7674
7700
  }
7675
- function buildStitchedHeader(layouts) {
7676
- const plan2 = readTracks(layouts[0].moov);
7677
- 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;
7678
7724
  const payloadBases = [];
7679
7725
  let payloadBytes = 0;
7680
- for (const layout of layouts) {
7726
+ for (const layout of sourceLayouts) {
7681
7727
  payloadBases.push(payloadBytes);
7682
7728
  payloadBytes += layout.payloadEnd - layout.payloadStart;
7683
7729
  }
7684
7730
  const template = layouts[0].moov;
7685
7731
  const root = boxAt(template, 0, 0, template.length);
7686
- 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;
7687
7736
  const buildMoov = (dataStart2) => {
7688
7737
  const traks = plan2.map((track, t) => {
7689
- const parts = chunks.flatMap((tracks, chunk) => {
7738
+ const parts = sources.flatMap((tracks, chunk) => {
7690
7739
  const part = tracks[t];
7691
7740
  return part ? [{ ...part, chunk }] : [];
7692
7741
  });
7742
+ const moov = t < chunkTrackCount ? template : audio.moov;
7693
7743
  const merged = {
7694
7744
  durations: parts.flatMap((part) => part.durations),
7695
7745
  sizes: parts.flatMap((part) => part.sizes),
@@ -7698,9 +7748,9 @@ function buildStitchedHeader(layouts) {
7698
7748
  chunkOffsets: parts.flatMap(
7699
7749
  (part) => part.chunkOffsets.map((offset) => {
7700
7750
  const i = part.chunk;
7701
- const { payloadStart, payloadEnd } = layouts[i];
7751
+ const { payloadStart, payloadEnd } = sourceLayouts[i];
7702
7752
  if (offset < payloadStart || offset >= payloadEnd) {
7703
- throw new Error(`[mp4Stitch] chunk ${i} has samples outside its mdat`);
7753
+ throw new Error(`[mp4Stitch] source ${i} has samples outside its mdat`);
7704
7754
  }
7705
7755
  return offset - payloadStart + payloadBases[i] + dataStart2;
7706
7756
  })
@@ -7716,20 +7766,25 @@ function buildStitchedHeader(layouts) {
7716
7766
  elst.writeBigInt64BE(BigInt(track.mediaTime), 12);
7717
7767
  elst.writeUInt32BE(65536, 20);
7718
7768
  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]),
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]),
7721
7775
  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) })
7776
+ mdia: (mdia) => rebuild(moov, mdia, {
7777
+ mdhd: (mdhd) => withDuration(moov, mdhd, mediaDuration),
7778
+ minf: (minf) => rebuild(moov, minf, { stbl: () => buildStbl(track.stsd, merged) })
7725
7779
  })
7726
7780
  });
7727
7781
  return { trak, shown };
7728
7782
  });
7729
7783
  let next = 0;
7784
+ const movieHeader = withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown)));
7730
7785
  return rebuild(template, root, {
7731
- mvhd: (mvhd) => withDuration(template, mvhd, Math.max(...traks.map((t) => t.shown))),
7732
- 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))
7733
7788
  });
7734
7789
  };
7735
7790
  const ftyp = layouts[0].ftyp;
@@ -7762,15 +7817,16 @@ async function copyPayload(path, layout, index, out) {
7762
7817
  );
7763
7818
  }
7764
7819
  }
7765
- async function writeStitchedMp4(chunkPaths, outputPath) {
7766
- const layouts = await Promise.all(chunkPaths.map((path) => readMp4Layout(fileRange(path))));
7767
- 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);
7768
7824
  mkdirSync8(dirname9(outputPath), { recursive: true });
7769
7825
  const workDir = mkdtempSync3(`${outputPath}.stitch-`);
7770
7826
  const out = createWriteStream(join19(workDir, "out.mp4"));
7771
7827
  try {
7772
7828
  out.write(header);
7773
- 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);
7774
7830
  out.end();
7775
7831
  await finished(out);
7776
7832
  renameSync3(join19(workDir, "out.mp4"), outputPath);
@@ -8345,7 +8401,8 @@ function buildLockedRenderConfig(input) {
8345
8401
  chunkCount: input.chunkCount,
8346
8402
  runtimeEnv: input.runtimeEnv,
8347
8403
  variables: config.variables,
8348
- ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {}
8404
+ ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
8405
+ ...input.separateAudio ? { separateAudio: true } : {}
8349
8406
  };
8350
8407
  }
8351
8408
  function carriesChunkAudio(config, options) {
@@ -8440,8 +8497,10 @@ function createVideoSourceShipper(input) {
8440
8497
  return result;
8441
8498
  };
8442
8499
  }
8443
- async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8444
- 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) {
8445
8504
  rejectUnsupportedDistributedFormat(config);
8446
8505
  validateNoGpuEncode({
8447
8506
  useGpu: false,
@@ -8577,6 +8636,100 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8577
8636
  });
8578
8637
  }
8579
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;
8580
8733
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
8581
8734
  let maxParallel = resolveMaxParallelChunks(config, totalFrames, megapixels, log);
8582
8735
  const measureFrameCost = chunkSizingUsesFrameCost(config) && planNeedsFrameCostMeasurement(compiled.html);
@@ -8587,24 +8740,9 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8587
8740
  observedAt: "pre-extract",
8588
8741
  log
8589
8742
  });
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
- });
8743
+ let stageStarted = Date.now();
8744
+ const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
8603
8745
  logPlanStage(log, "extract", stageStarted);
8604
- if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
8605
- if (extractResult.extractionResult) {
8606
- assertVideoExtractionSucceeded(extractResult.extractionResult);
8607
- }
8608
8746
  const stagedVideoFrames = join22(compiledDir, "__hyperframes_video_frames");
8609
8747
  const videoFramesDst = join22(planDir, "video-frames");
8610
8748
  if (existsSync15(videoFramesDst)) rmSync8(videoFramesDst, { recursive: true, force: true });
@@ -8660,23 +8798,18 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8660
8798
  "utf-8"
8661
8799
  );
8662
8800
  const digests = options.digests ?? /* @__PURE__ */ new Map();
8663
- const chunksCarryAudio2 = carriesChunkAudio(config, options);
8664
- 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");
8665
8805
  stageStarted = Date.now();
8666
8806
  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)),
8807
+ separateAudio ? null : mixPlanAudio(
8808
+ c,
8809
+ finalCompiledDir,
8810
+ audioSeconds,
8811
+ chunksCarryAudio ? planMixPcmPath : void 0
8812
+ ).finally(() => logPlanStage(log, "audio", stageStarted)),
8680
8813
  (async () => {
8681
8814
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
8682
8815
  await prehashPlanFiles(dir, digests);
@@ -8726,22 +8859,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8726
8859
  );
8727
8860
  const chunks = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
8728
8861
  const audioResult = audioOutcome.value;
8729
- if (audioResult.audioError) {
8862
+ if (audioResult?.audioError) {
8730
8863
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
8731
8864
  }
8732
8865
  const planAudioPath = join22(planDir, PLAN_AUDIO_RELATIVE_PATH);
8733
- if (audioResult.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8866
+ if (audioResult?.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8734
8867
  renameSync4(audioResult.audioOutputPath, planAudioPath);
8735
8868
  }
8736
- if (chunksCarryAudio2 && existsSync15(planAudioPath)) {
8869
+ if (chunksCarryAudio && existsSync15(planAudioPath)) {
8737
8870
  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);
8871
+ await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
8745
8872
  logPlanStage(log, "audio-peak", stageStarted);
8746
8873
  if (options.sliceChunkAudio) {
8747
8874
  stageStarted = Date.now();
@@ -8767,7 +8894,8 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8767
8894
  effectiveChunkSize,
8768
8895
  chunkCount,
8769
8896
  runtimeEnv,
8770
- chunksCarryAudio: chunksCarryAudio2
8897
+ chunksCarryAudio,
8898
+ separateAudio
8771
8899
  });
8772
8900
  const compositionJson = {
8773
8901
  durationSeconds: job.duration ?? 0,
@@ -8812,7 +8940,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8812
8940
  fontSnapshotSha,
8813
8941
  durationSeconds: job.duration ?? 0,
8814
8942
  totalFrames,
8815
- hasAudio: audioResult.hasAudio,
8943
+ hasAudio: audioResult?.hasAudio ?? false,
8816
8944
  digests
8817
8945
  });
8818
8946
  logPlanStage(log, "freeze", stageStarted);
@@ -8837,6 +8965,33 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8837
8965
  producerVersion
8838
8966
  };
8839
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
+ }
8840
8995
  async function plan(projectDir, config, planDir, options = {}) {
8841
8996
  const executionPlan = await buildLocalExecutionPlan(projectDir, config, planDir, options);
8842
8997
  return {
@@ -9445,7 +9600,8 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9445
9600
  )
9446
9601
  ),
9447
9602
  chunksCarryAudio: options.chunksCarryAudio,
9448
- sliceChunkAudio: true
9603
+ sliceChunkAudio: true,
9604
+ deferAudio: options.deferAudio
9449
9605
  });
9450
9606
  return await publishExecutionPlan(stagingRoot, publisher, { digests, log: config.logger });
9451
9607
  } catch (error) {
@@ -9781,7 +9937,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
9781
9937
  return mergePngFrameDirs(chunkPaths, outputPath, plan2.totalFrames, audioPath, start);
9782
9938
  }
9783
9939
  if (chunksAreStitchedMp4(planDir, cfr)) {
9784
- await writeStitchedMp4(chunkPaths, outputPath);
9940
+ const separateAudio = stitchesSeparateAudio(planDir) && audioPath !== null;
9941
+ await writeStitchedMp4(chunkPaths, outputPath, separateAudio ? audioPath : void 0);
9785
9942
  return {
9786
9943
  outputPath,
9787
9944
  durationMs: Date.now() - start,
@@ -9979,16 +10136,20 @@ async function runAssembleFfmpeg(args, step, signal, timeout) {
9979
10136
  }
9980
10137
  }
9981
10138
  function chunksAreStitchedMp4(planDir, cfr) {
9982
- if (!chunksCarryAudio(planDir)) return false;
10139
+ const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
10140
+ if (!chunksCarryAudio && !separateAudio) return false;
9983
10141
  if (cfr) {
9984
- 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");
9985
10143
  }
9986
10144
  return true;
9987
10145
  }
9988
- function chunksCarryAudio(planDir) {
10146
+ function stitchesSeparateAudio(planDir) {
10147
+ return lockedAudio(planDir).separateAudio === true;
10148
+ }
10149
+ function lockedAudio(planDir) {
9989
10150
  const encoderJsonPath = join26(planDir, "meta", "encoder.json");
9990
- if (!existsSync18(encoderJsonPath)) return false;
9991
- return JSON.parse(readFileSync11(encoderJsonPath, "utf-8")).chunksCarryAudio === true;
10151
+ if (!existsSync18(encoderJsonPath)) return {};
10152
+ return JSON.parse(readFileSync11(encoderJsonPath, "utf-8"));
9992
10153
  }
9993
10154
  function readCfrEncoder(planDir, format) {
9994
10155
  if (format !== "mp4") {
@@ -10062,11 +10223,12 @@ import {
10062
10223
  calculateOptimalWorkers as calculateOptimalWorkers2,
10063
10224
  classifyCaptureFailure as classifyCaptureFailure4,
10064
10225
  closeCaptureSession as closeCaptureSession10,
10065
- createCaptureSession as createCaptureSession9,
10226
+ createCaptureSession as createCaptureSession10,
10066
10227
  createFrameLookupTable as createFrameLookupTable5,
10067
10228
  deriveBeginFrameProbeTimeTicks as deriveBeginFrameProbeTimeTicks2,
10068
10229
  getEncoderPreset as getEncoderPreset6,
10069
- initializeSession as initializeSession9,
10230
+ initializeSession as initializeSession10,
10231
+ measureVideoShownBoxes,
10070
10232
  probeBeginFrameLiveness as probeBeginFrameLiveness2,
10071
10233
  readWebGlVendorInfoFromCanvas,
10072
10234
  resolveConfig as resolveConfig5
@@ -10095,9 +10257,9 @@ function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
10095
10257
  return { startFrame, endFrame };
10096
10258
  }
10097
10259
  var distributedCaptureSessionDependencies = {
10098
- createCaptureSession: createCaptureSession9,
10260
+ createCaptureSession: createCaptureSession10,
10099
10261
  assertSwiftShader,
10100
- initializeSession: initializeSession9,
10262
+ initializeSession: initializeSession10,
10101
10263
  closeCaptureSession: closeCaptureSession10,
10102
10264
  readWebGlVendorInfo: readWebGlVendorInfoFromCanvas
10103
10265
  };
@@ -10288,34 +10450,15 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10288
10450
  const framesDir = join27(workDir, "captured-frames");
10289
10451
  mkdirSync14(framesDir, { recursive: true });
10290
10452
  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
- }) : [];
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();
10300
10457
  const remoteVideoIds = new Set(
10301
10458
  planVideos?.extracted.filter(
10302
10459
  (video) => video.deferredRange && Object.hasOwn(remoteSources, video.deferredRange.sourcePath)
10303
10460
  ).map((video) => video.videoId)
10304
10461
  );
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
10462
  const fileServer = await createFileServer({
10320
10463
  projectDir: compiledDir,
10321
10464
  compiledDir,
@@ -10331,8 +10474,12 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10331
10474
  videoCount: planVideos?.videos.length ?? 0,
10332
10475
  html: readFileSync12(join27(compiledDir, "index.html"), "utf-8"),
10333
10476
  // 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)
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
+ )
10336
10483
  });
10337
10484
  const chunkWorkerCount = calculateOptimalWorkers2(framesInChunk, void 0, cfg);
10338
10485
  let session = null;
@@ -10345,6 +10492,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10345
10492
  const capturePerfs = [];
10346
10493
  const captureAttempts = [];
10347
10494
  let forceScreenshotForChunk = encoder.forceScreenshot;
10495
+ let shownBoxes;
10348
10496
  try {
10349
10497
  if (chunkWorkerCount === 1 || !forceScreenshotForChunk) {
10350
10498
  const bootStarted = Date.now();
@@ -10355,6 +10503,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10355
10503
  cfg
10356
10504
  );
10357
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
+ );
10358
10511
  const browserSelectedScreenshot = session.launchCaptureMode === "screenshot";
10359
10512
  const beginFrameStalled = !browserSelectedScreenshot && await beginFrameSessionNeedsScreenshotFallback(session);
10360
10513
  if (browserSelectedScreenshot) {
@@ -10396,6 +10549,29 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10396
10549
  session = null;
10397
10550
  }
10398
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);
10399
10575
  const captureStarted = Date.now();
10400
10576
  captureMode = await runCaptureWithScreenshotFallback({
10401
10577
  forceScreenshot: forceScreenshotForChunk,
@@ -10586,6 +10762,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10586
10762
  encodeStageMs,
10587
10763
  workers: chunkWorkerCount,
10588
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 ?? []),
10589
10767
  sha256,
10590
10768
  outputKind,
10591
10769
  producerVersion: plan2.producerVersion,
@@ -10897,10 +11075,11 @@ function validateIntDimension(field, value) {
10897
11075
  }
10898
11076
 
10899
11077
  // src/services/distributed/projectHash.ts
10900
- 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";
10901
11079
  import { createHash as createHash6 } from "node:crypto";
10902
11080
  import { join as join29 } from "node:path";
10903
11081
  var SITE_ID_SCHEME = "site-v2\0";
11082
+ var READ_CHUNK_BYTES = 256 * 1024;
10904
11083
  function listSiteFiles(projectDir) {
10905
11084
  const files = [];
10906
11085
  const ancestors = /* @__PURE__ */ new Set([realpathSync2(projectDir)]);
@@ -10933,10 +11112,19 @@ function listSiteFiles(projectDir) {
10933
11112
  }
10934
11113
  function hashProjectDir(projectDir, files = listSiteFiles(projectDir)) {
10935
11114
  const hash = createHash6("sha256").update(SITE_ID_SCHEME);
11115
+ const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
10936
11116
  for (const rel of files) {
10937
11117
  hash.update(rel);
10938
11118
  hash.update("\0");
10939
- 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
+ }
10940
11128
  }
10941
11129
  return hash.digest("hex").slice(0, 16);
10942
11130
  }
@@ -10984,6 +11172,7 @@ export {
10984
11172
  assemble,
10985
11173
  assembleV2,
10986
11174
  buildChunkSlices,
11175
+ buildPlanAudio,
10987
11176
  buildStitchedHeader,
10988
11177
  chunksAreStitchedMp4,
10989
11178
  createPlanV2FromExecutionPlan,
@@ -11001,6 +11190,7 @@ export {
11001
11190
  plan,
11002
11191
  planV2,
11003
11192
  planV2WithPublisher,
11193
+ plansSeparateAudio,
11004
11194
  publishPlanV2FromExecutionPlan,
11005
11195
  publishPlanV2FromV1,
11006
11196
  readMp4Layout,
@@ -11013,6 +11203,7 @@ export {
11013
11203
  renderChunkV2,
11014
11204
  resolveChunkPlan,
11015
11205
  resolvePlanAudioPath,
11206
+ stitchesSeparateAudio,
11016
11207
  validateDistributedRenderConfig,
11017
11208
  validatePlanV2MaterializedTarget,
11018
11209
  validateVariablesPayload