@hyperframes/producer 0.8.186 → 0.8.189

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.
@@ -7,10 +7,10 @@ import {
7
7
  linkSync,
8
8
  mkdirSync as mkdirSync10,
9
9
  renameSync as renameSync4,
10
- rmSync as rmSync8,
10
+ rmSync as rmSync9,
11
11
  writeFileSync as writeFileSync6
12
12
  } from "node:fs";
13
- import { extname as extname4, isAbsolute as isAbsolute3, join as join22, relative as relative5, sep as sep2 } from "node:path";
13
+ import { extname as extname4, isAbsolute as isAbsolute3, join as join21, relative as relative5, sep as sep2 } from "node:path";
14
14
  import {
15
15
  fpsToNumber as fpsToNumber10,
16
16
  HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
@@ -67,7 +67,7 @@ import {
67
67
  mkdirSync as mkdirSync6,
68
68
  readFileSync as readFileSync6,
69
69
  readdirSync as readdirSync3,
70
- rmSync as rmSync6,
70
+ rmSync as rmSync7,
71
71
  statSync as statSync4,
72
72
  writeFileSync as writeFileSync3,
73
73
  copyFileSync as copyFileSync4,
@@ -116,7 +116,7 @@ import {
116
116
  augmentProtocolTimeoutError,
117
117
  augmentPageNavigationTimeoutError
118
118
  } from "@hyperframes/engine";
119
- import { join as join14, dirname as dirname7, resolve as resolve6 } from "path";
119
+ import { join as join13, dirname as dirname7, resolve as resolve6 } from "path";
120
120
  import { randomUUID } from "crypto";
121
121
 
122
122
  // src/services/fileServer.ts
@@ -3444,7 +3444,9 @@ async function discoverMediaFromBrowser(page) {
3444
3444
  const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
3445
3445
  const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
3446
3446
  if (!id) return;
3447
- const src = htmlEl.currentSrc || htmlEl.src || htmlEl.getAttribute("src") || "";
3447
+ const ownSrc = htmlEl.getAttribute("src");
3448
+ const blankSrc = ownSrc !== null && ownSrc.trim() === "" && !htmlEl.querySelector("source[src]");
3449
+ const src = blankSrc ? "" : htmlEl.currentSrc || htmlEl.src || ownSrc || "";
3448
3450
  const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
3449
3451
  const endRaw = htmlEl.getAttribute("data-end");
3450
3452
  const durationRaw = htmlEl.getAttribute("data-duration");
@@ -4947,1241 +4949,1225 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
4947
4949
  }
4948
4950
 
4949
4951
  // src/services/render/stages/audioStage.ts
4950
- import { join as join9 } from "node:path";
4952
+ import { join as join10 } from "node:path";
4953
+ import { rmSync as rmSync5 } from "node:fs";
4951
4954
  import {
4952
4955
  MIXED_AUDIO_FILENAME,
4953
4956
  audioFailureDiagnostics,
4954
4957
  processCompositionAudio
4955
4958
  } from "@hyperframes/engine";
4956
- function triggerFirst(failures, trigger) {
4957
- return [trigger, ...(failures ?? []).filter((failure) => failure !== trigger)];
4959
+
4960
+ // src/services/render/audioPadTrim.ts
4961
+ import { spawn } from "node:child_process";
4962
+ import { mkdtempSync as mkdtempSync2, renameSync as renameSync2, rmSync as rmSync4 } from "node:fs";
4963
+ import { dirname as dirname5, join as join9 } from "node:path";
4964
+ import {
4965
+ DEFAULT_CONFIG,
4966
+ describeFfmpegFailure as describeFfmpegFailure2,
4967
+ encodeAac,
4968
+ extractAudioMetadata as extractAudioMetadata3,
4969
+ getFfprobeBinary,
4970
+ ManagedChildProcess,
4971
+ runFfmpeg as runFfmpeg4,
4972
+ trackChildProcess
4973
+ } from "@hyperframes/engine";
4974
+ import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
4975
+
4976
+ // src/services/render/audioTruePeak.ts
4977
+ import {
4978
+ aacSegmentCount,
4979
+ describeFfmpegFailure,
4980
+ extractAudioMetadata as extractAudioMetadata2,
4981
+ runFfmpeg as runFfmpeg3
4982
+ } from "@hyperframes/engine";
4983
+ var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
4984
+ function checkedDbfs(value) {
4985
+ if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
4986
+ throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
4987
+ }
4988
+ return value;
4958
4989
  }
4959
- async function runAudioStage(input) {
4960
- const {
4961
- projectDir,
4962
- workDir,
4963
- compiledDir,
4964
- duration,
4965
- ffmpegProcessTimeout,
4966
- audioGain,
4967
- audios,
4968
- masterFxChain,
4969
- urlByAbsentLocalPath,
4970
- keepEncoderInputAt,
4971
- abortSignal,
4972
- assertNotAborted,
4973
- log
4974
- } = input;
4975
- const stage3Start = Date.now();
4976
- const audioOutputPath = join9(workDir, MIXED_AUDIO_FILENAME);
4977
- let hasAudio = false;
4978
- let audioError;
4979
- let audioFailures;
4980
- if (audios.length > 0) {
4981
- let audioResult;
4982
- try {
4983
- audioResult = await processCompositionAudio(
4984
- audios,
4985
- projectDir,
4986
- join9(workDir, "audio-work"),
4987
- audioOutputPath,
4988
- duration,
4989
- abortSignal,
4990
- {
4991
- ffmpegProcessTimeout,
4992
- audioGain,
4993
- masterFxChain,
4994
- urlByAbsentLocalPath,
4995
- keepEncoderInputAt
4996
- },
4997
- compiledDir
4998
- );
4999
- } catch (err) {
5000
- assertNotAborted();
5001
- const detail = err instanceof Error ? err.message : String(err);
5002
- return {
5003
- audioOutputPath,
5004
- hasAudio: false,
5005
- audioProcessMs: Date.now() - stage3Start,
5006
- audioError: detail,
5007
- // Synthesised rather than left undefined. The warning policy reads
5008
- // owner, retryability, reason and stage off this list, so reporting the
5009
- // FATAL failure — the one that took the whole mix down — with an empty
5010
- // one gave it strictly less classification than a single dropped track
5011
- // gets, which is the opposite of what this stage exists to do.
5012
- // "internal" is the honest bucket: the stages enumerate ffmpeg steps
5013
- // and this is the FX render, which is none of them.
5014
- audioFailures: [
5015
- {
5016
- stage: "internal",
5017
- reason: "internal",
5018
- owner: "system",
5019
- retryable: false,
5020
- detail: detail.slice(0, 2e3)
5021
- }
5022
- ]
5023
- };
5024
- }
5025
- assertNotAborted();
5026
- const interrupted = audioResult.failures?.find(
5027
- (failure) => failure.reason === "external_interruption"
5028
- );
5029
- if (interrupted) {
5030
- throw encoderFailureError("Audio processing failed", {
5031
- error: interrupted.detail,
5032
- failureReason: "external_interruption",
5033
- audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
5034
- });
5035
- }
5036
- hasAudio = audioResult.success;
5037
- audioFailures = audioResult.failures;
5038
- if (!hasAudio) {
5039
- audioError = audioResult.error ?? "audio mix failed for an unknown reason";
5040
- for (const failure of audioResult.failures ?? []) {
5041
- log?.warn("Audio track failed", {
5042
- elementId: failure.elementId,
5043
- stage: failure.stage,
5044
- reason: failure.reason,
5045
- owner: failure.owner,
5046
- retryable: failure.retryable,
5047
- detail: failure.detail
5048
- });
5049
- }
4990
+ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
4991
+ let firstBlock;
4992
+ let laterBlocks = Number.NEGATIVE_INFINITY;
4993
+ let partialLine = "";
4994
+ const readLines = (chunk) => {
4995
+ const lines = (partialLine + chunk).split(/\r|\n/);
4996
+ partialLine = lines.pop() ?? "";
4997
+ for (const line of lines) {
4998
+ const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
4999
+ if (!ftpk) continue;
5000
+ const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
5001
+ if (firstBlock === void 0) firstBlock = block;
5002
+ else laterBlocks = Math.max(laterBlocks, block);
5050
5003
  }
5004
+ };
5005
+ const result = await runFfmpeg3(
5006
+ ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
5007
+ { signal, timeout: timeoutMs, onStderr: readLines }
5008
+ );
5009
+ if (!result.success) {
5010
+ throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
5051
5011
  }
5052
- const audioProcessMs = Date.now() - stage3Start;
5012
+ const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
5053
5013
  return {
5054
- audioOutputPath,
5055
- hasAudio,
5056
- audioProcessMs,
5057
- audioError,
5058
- audioFailures
5014
+ peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
5015
+ firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
5016
+ laterBlocks: checkedDbfs(laterBlocks)
5059
5017
  };
5060
5018
  }
5061
-
5062
- // src/services/render/stages/captureStage.ts
5063
- import { existsSync as existsSync8, readdirSync, statfsSync as statfsSync2 } from "node:fs";
5064
- import { join as join10 } from "node:path";
5065
- import {
5066
- classifyCaptureFailure as classifyCaptureFailure2,
5067
- frameFileExtension,
5068
- captureFrame,
5069
- captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
5070
- verifyDiskDrawElementSamples,
5071
- writeCapturedFrame,
5072
- closeCaptureSession as closeCaptureSession5,
5073
- completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
5074
- createCaptureSession as createCaptureSession5,
5075
- getCapturePerfSummary as getCapturePerfSummary3,
5076
- initializeSession as initializeSession5,
5077
- prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
5078
- } from "@hyperframes/engine";
5079
- function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
5080
- return workerCount > 1;
5081
- }
5082
- var JPEG_RAW_RGBA_DIVISOR = 8;
5083
- var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
5084
- var DISK_PROJECTION_SAMPLE_FRAMES = 10;
5085
- function estimateDiskCaptureBytes(totalFrames, captureOptions) {
5086
- const scale = captureOptions.deviceScaleFactor ?? 1;
5087
- const outputWidth = Math.ceil(captureOptions.width * scale);
5088
- const outputHeight = Math.ceil(captureOptions.height * scale);
5089
- const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
5090
- return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
5091
- rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
5092
- ) : rawBytes;
5093
- }
5094
- function freeDiskBytesAt(path) {
5095
- try {
5096
- const stat = statfsSync2(path);
5097
- return stat.bavail * stat.bsize;
5098
- } catch {
5099
- return null;
5100
- }
5019
+ async function measureTruePeakDbfs(audioPath, options) {
5020
+ const { timeoutMs, signal } = options;
5021
+ const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
5022
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
5023
+ const block = 4800;
5024
+ const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
5025
+ if (count === 1) return wholeFile();
5026
+ const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
5027
+ const peers = new AbortController();
5028
+ const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
5029
+ const scans = await Promise.all(
5030
+ Array.from({ length: count }, (_, k) => {
5031
+ const start = k === 0 ? 0 : cut(k) - block;
5032
+ const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
5033
+ return scanTruePeak(
5034
+ audioPath,
5035
+ `atrim=start_sample=${start}${end},`,
5036
+ timeoutMs,
5037
+ segmentSignal
5038
+ );
5039
+ })
5040
+ ).catch((error) => {
5041
+ peers.abort();
5042
+ throw error;
5043
+ });
5044
+ const last = scans[count - 1];
5045
+ const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
5046
+ if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
5047
+ return wholeFile();
5101
5048
  }
5102
- function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
5103
- const freeBytes = freeDiskBytes(framesDir);
5104
- const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
5105
- if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
5106
- return { available: true, estimatedBytes, freeBytes };
5107
- }
5108
- return { available: false, estimatedBytes, freeBytes };
5049
+
5050
+ // src/services/render/audioPadTrim.ts
5051
+ var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
5052
+ var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
5053
+ var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
5054
+ var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
5055
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
5056
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
5109
5057
  }
5110
- function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
5111
- const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
5112
- if (workerCount > 1 && oneWorker.enabled) {
5113
- 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.`;
5058
+ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
5059
+ const delta = targetDurationSeconds - sourceDurationSeconds;
5060
+ const targetSec = formatSeconds(targetDurationSeconds);
5061
+ if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
5062
+ return {
5063
+ operation: "copy",
5064
+ steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
5065
+ cleanupPaths: []
5066
+ };
5114
5067
  }
5115
- switch (oneWorker.reason) {
5116
- case "format_excluded":
5117
- return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
5118
- case "disabled_by_config":
5119
- return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
5120
- case "duration_cap":
5121
- 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.";
5122
- default:
5123
- return "Free up disk space.";
5068
+ if (delta > 0) {
5069
+ return {
5070
+ operation: "pad",
5071
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5072
+ cleanupPaths: []
5073
+ };
5124
5074
  }
5075
+ return {
5076
+ operation: "trim",
5077
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5078
+ cleanupPaths: []
5079
+ };
5125
5080
  }
5126
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
5127
- const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
5128
- return new Error(
5129
- `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}`
5130
- );
5081
+ function formatSeconds(sec) {
5082
+ return sec.toFixed(6);
5131
5083
  }
5132
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
5133
- const headroom = inspectDiskCaptureHeadroom(
5134
- framesDir,
5135
- totalFrames,
5136
- captureOptions,
5137
- freeDiskBytes
5138
- );
5139
- if (headroom.available) return;
5140
- throw diskCaptureShortfallError(
5141
- headroom.estimatedBytes,
5142
- headroom.freeBytes,
5143
- framesDir,
5144
- "estimate",
5145
- remedy
5146
- );
5084
+ function sanitizeProbeFailure(reason, paths) {
5085
+ const message = reason instanceof Error ? reason.message : String(reason);
5086
+ const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
5087
+ return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
5147
5088
  }
5148
- function measureCaptureFrameBytes(workDir, framesDir) {
5149
- let total = sampleDirectoryBytes(framesDir);
5150
- if (!existsSync8(workDir)) return total;
5151
- for (const attempt of readdirSync(workDir)) {
5152
- if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
5153
- total += sampleDirectoryBytes(join10(workDir, attempt));
5154
- continue;
5155
- }
5156
- if (!/^capture-attempt-\d+$/.test(attempt)) continue;
5157
- const attemptDir = join10(workDir, attempt);
5158
- for (const name of readdirSync(attemptDir)) {
5159
- if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join10(attemptDir, name));
5160
- }
5089
+ async function padOrTrimAudioToVideoFrameCount(input) {
5090
+ const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
5091
+ const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
5092
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5093
+ const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
5094
+ const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
5095
+ const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
5096
+ const [videoResult, audioResult] = await Promise.allSettled([
5097
+ probeVideo(input.videoPath),
5098
+ probeAudio(input.audioPath, input.signal)
5099
+ ]);
5100
+ const probePaths = [input.videoPath, input.audioPath, input.outputPath];
5101
+ if (videoResult.status === "rejected") {
5102
+ return failResult(
5103
+ input.outputPath,
5104
+ 0,
5105
+ audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
5106
+ `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
5107
+ videoResult.reason,
5108
+ probePaths
5109
+ )}`
5110
+ );
5161
5111
  }
5162
- return total;
5163
- }
5164
- function createDiskCaptureProjection(input) {
5165
- const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
5166
- let checked = false;
5167
- return (capturedFrames) => {
5168
- if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
5169
- const measured = measureBytes();
5170
- if (measured <= 0) return;
5171
- checked = true;
5172
- const remaining = totalFrames - capturedFrames;
5173
- const freeBytes = freeDiskBytes(framesDir);
5174
- if (remaining <= 0 || freeBytes === null) return;
5175
- const needBytes = Math.ceil(measured / capturedFrames * remaining);
5176
- if (needBytes > freeBytes * 0.9) {
5177
- throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
5178
- }
5179
- };
5180
- }
5181
- async function runCaptureStage(input) {
5182
- const {
5183
- fileServer,
5184
- workDir,
5185
- framesDir,
5186
- job,
5187
- totalFrames,
5188
- cfg,
5189
- plan: plan2,
5190
- log,
5191
- captureAttempts,
5192
- buildCaptureOptions,
5193
- createRenderVideoFrameInjector,
5194
- abortSignal,
5195
- onProgress,
5196
- frameRange,
5197
- dedupPerfs
5198
- } = input;
5199
- let { probeSession } = input;
5200
- let { workerCount } = plan2;
5201
- const { forceScreenshot } = plan2;
5202
- let lastBrowserConsole = [];
5203
- let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
5204
- const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
5205
- if (frameRange !== void 0) {
5206
- if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
5207
- throw new Error(
5208
- `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
5209
- );
5210
- }
5211
- const rangeFrames = frameRange.endFrame - frameRange.startFrame;
5212
- if (rangeFrames !== totalFrames) {
5213
- throw new Error(
5214
- `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
5215
- );
5112
+ if (audioResult.status === "rejected") {
5113
+ return failResult(
5114
+ input.outputPath,
5115
+ 0,
5116
+ 0,
5117
+ `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
5118
+ audioResult.reason,
5119
+ probePaths
5120
+ )}`
5121
+ );
5122
+ }
5123
+ const videoInfo = videoResult.value;
5124
+ const audioInfo = audioResult.value;
5125
+ if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
5126
+ return failResult(
5127
+ input.outputPath,
5128
+ 0,
5129
+ audioInfo.durationSeconds,
5130
+ `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
5131
+ );
5132
+ }
5133
+ const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
5134
+ const plan2 = buildPadTrimAudioPlan(
5135
+ input.audioPath,
5136
+ input.outputPath,
5137
+ audioInfo.durationSeconds,
5138
+ targetDurationSeconds
5139
+ );
5140
+ try {
5141
+ for (const step of plan2.steps) {
5142
+ const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
5143
+ if (!ffmpegResult.success) {
5144
+ return {
5145
+ success: false,
5146
+ outputPath: input.outputPath,
5147
+ targetDurationSeconds,
5148
+ sourceDurationSeconds: audioInfo.durationSeconds,
5149
+ operation: plan2.operation,
5150
+ error: ffmpegResult.error,
5151
+ failureReason: ffmpegResult.failureReason
5152
+ };
5153
+ }
5216
5154
  }
5155
+ } catch (err) {
5156
+ return {
5157
+ success: false,
5158
+ outputPath: input.outputPath,
5159
+ targetDurationSeconds,
5160
+ sourceDurationSeconds: audioInfo.durationSeconds,
5161
+ operation: plan2.operation,
5162
+ error: `audioPadTrim: failed to materialize ${plan2.operation}: ${err instanceof Error ? err.message : String(err)}`
5163
+ };
5164
+ } finally {
5165
+ for (const path of plan2.cleanupPaths) rmSync4(path, { force: true });
5217
5166
  }
5218
- const captureOptions = buildCaptureOptions();
5219
- const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
5220
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
5221
- const checkDiskProjection = createDiskCaptureProjection({
5222
- framesDir,
5223
- totalFrames,
5224
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
5225
- remedy
5226
- });
5227
- if (workerCount > 1) {
5228
- const attempts = await executeDiskCaptureWithAdaptiveRetry({
5229
- serverUrl: fileServer.url,
5230
- workDir,
5231
- framesDir,
5232
- totalFrames,
5233
- initialWorkerCount: workerCount,
5234
- allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
5235
- frameExt: frameFileExtension(captureOptions.format),
5236
- captureOptions,
5237
- createBeforeCaptureHook: createRenderVideoFrameInjector,
5238
- abortSignal,
5239
- frameRangeStart: frameRange?.startFrame,
5240
- dedupPerfs,
5241
- onProgress: (progress) => {
5242
- if (progress.latestWorkerPhase) {
5243
- if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
5244
- return;
5245
- }
5246
- if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
5247
- job.framesRendered = progress.capturedFrames;
5248
- checkDiskProjection(progress.capturedFrames);
5249
- const frameProgress = progress.capturedFrames / progress.totalFrames;
5250
- reportFrameProgress(
5251
- job,
5252
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
5253
- Math.round(25 + frameProgress * 45),
5254
- onProgress,
5255
- progress.capturedFrames,
5256
- progress.totalFrames
5257
- );
5258
- },
5259
- cfg: captureCfg,
5260
- log
5167
+ let loweredDb = 0;
5168
+ if (probeTruePeak) {
5169
+ const correction = await enforceAacTruePeak({
5170
+ audioPath: input.outputPath,
5171
+ targetDurationSeconds,
5172
+ signal: input.signal,
5173
+ probeTruePeak,
5174
+ encode
5261
5175
  });
5262
- captureAttempts.push(...attempts);
5263
- const lastAttempt = attempts[attempts.length - 1];
5264
- if (lastAttempt) {
5265
- workerCount = lastAttempt.workers;
5266
- }
5267
- if (probeSession) {
5268
- captureBeyondViewport = probeSession.options.captureBeyondViewport;
5269
- lastBrowserConsole = probeSession.browserConsoleBuffer;
5270
- await closeCaptureSession5(probeSession);
5271
- probeSession = null;
5176
+ if (!correction.success) {
5177
+ return {
5178
+ success: false,
5179
+ outputPath: input.outputPath,
5180
+ targetDurationSeconds,
5181
+ sourceDurationSeconds: audioInfo.durationSeconds,
5182
+ operation: plan2.operation,
5183
+ error: correction.error,
5184
+ failureReason: correction.failureReason
5185
+ };
5272
5186
  }
5273
- } else {
5274
- const sequential = await runSequentialDiskCapture(
5275
- input,
5276
- captureOptions,
5277
- captureCfg,
5278
- checkDiskProjection
5279
- );
5280
- probeSession = null;
5281
- lastBrowserConsole = sequential.lastBrowserConsole;
5282
- captureBeyondViewport = sequential.captureBeyondViewport;
5187
+ loweredDb = correction.loweredDb ?? 0;
5283
5188
  }
5284
- return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
5189
+ return {
5190
+ success: true,
5191
+ outputPath: input.outputPath,
5192
+ targetDurationSeconds,
5193
+ sourceDurationSeconds: audioInfo.durationSeconds,
5194
+ operation: plan2.operation,
5195
+ ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
5196
+ };
5285
5197
  }
5286
- async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
5287
- const {
5288
- fileServer,
5289
- framesDir,
5290
- totalFrames,
5291
- log,
5292
- createRenderVideoFrameInjector,
5293
- abortSignal,
5294
- assertNotAborted,
5295
- frameRange
5296
- } = input;
5297
- let { probeSession } = input;
5298
- let lastBrowserConsole = [];
5299
- let captureBeyondViewport;
5300
- const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
5301
- const rangeFrames = rangeEnd - rangeStart;
5302
- const frameExt = frameFileExtension(captureOptions.format);
5303
- let transientRetriesUsed = 0;
5304
- let resumeFrom = 0;
5305
- while (true) {
5306
- const videoInjector = createRenderVideoFrameInjector();
5307
- const session = probeSession ?? await createCaptureSession5(
5308
- fileServer.url,
5309
- framesDir,
5310
- captureOptions,
5311
- videoInjector,
5312
- captureCfg
5313
- );
5314
- captureBeyondViewport = session.options.captureBeyondViewport;
5315
- try {
5316
- if (probeSession) {
5317
- prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
5318
- probeSession = null;
5198
+ async function constrainAacTruePeak(input) {
5199
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5200
+ return enforceAacTruePeak({
5201
+ audioPath: input.audioPath,
5202
+ targetDurationSeconds: input.targetDurationSeconds,
5203
+ signal: input.signal,
5204
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
5205
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
5206
+ });
5207
+ }
5208
+ async function enforceAacTruePeak(input) {
5209
+ let scratchDir;
5210
+ try {
5211
+ scratchDir = mkdtempSync2(join9(dirname5(input.audioPath), ".true-peak-"));
5212
+ const correctedPath = join9(scratchDir, "audio.m4a");
5213
+ const measurements = [];
5214
+ let attenuationDb = 0;
5215
+ let measuredPath = input.audioPath;
5216
+ for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
5217
+ const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
5218
+ measurements.push(
5219
+ `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
5220
+ );
5221
+ if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
5222
+ return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
5319
5223
  }
5320
- await ensureSessionReady(session);
5321
- assertNotAborted();
5322
- lastBrowserConsole = session.browserConsoleBuffer;
5323
- await captureSessionFrames(session, {
5324
- input,
5325
- rangeStart,
5326
- rangeEnd,
5327
- resumeFrom,
5328
- checkDiskProjection
5329
- });
5330
- break;
5331
- } catch (error) {
5332
- lastBrowserConsole = session.browserConsoleBuffer;
5333
- const resume = planTransientResume(error, {
5334
- abortSignal,
5335
- rangeFrames,
5336
- framesDir,
5337
- frameExt,
5338
- retriesUsed: transientRetriesUsed
5339
- });
5340
- if (resume !== null) {
5341
- transientRetriesUsed++;
5342
- resumeFrom = resume;
5343
- log.warn(
5344
- "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
5345
- {
5346
- resumeFrom,
5347
- rangeFrames,
5348
- transientRetriesUsed,
5349
- error: error instanceof Error ? error.message : String(error)
5350
- }
5351
- );
5352
- continue;
5224
+ if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
5225
+ if (measuredPath !== correctedPath) return { success: true };
5226
+ renameSync2(correctedPath, input.audioPath);
5227
+ return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
5353
5228
  }
5354
- throw wrapCaptureStageError(error, lastBrowserConsole);
5355
- } finally {
5356
- lastBrowserConsole = session.browserConsoleBuffer;
5357
- await closeCaptureSession5(session);
5229
+ if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
5230
+ const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
5231
+ attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
5232
+ const result = await input.encode(
5233
+ input.audioPath,
5234
+ correctedPath,
5235
+ input.targetDurationSeconds,
5236
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
5237
+ );
5238
+ if (!result.success) {
5239
+ return {
5240
+ success: false,
5241
+ error: sanitizeProbeFailure(
5242
+ result.error ?? "audioPadTrim: failed to constrain AAC true peak",
5243
+ [input.audioPath, correctedPath]
5244
+ ),
5245
+ failureReason: result.failureReason
5246
+ };
5247
+ }
5248
+ measuredPath = correctedPath;
5358
5249
  }
5359
- }
5360
- return { lastBrowserConsole, captureBeyondViewport };
5361
- }
5362
- function resolveCaptureRange(frameRange, totalFrames) {
5363
- return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
5364
- }
5365
- function planTransientResume(error, ctx) {
5366
- const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
5367
- const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
5368
- const [firstMissing] = missing;
5369
- if (!firstMissing) return null;
5370
- return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
5371
- }
5372
- async function ensureSessionReady(session) {
5373
- if (!session.isInitialized) {
5374
- await initializeSession5(session);
5375
- return;
5376
- }
5377
- if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
5378
- await completeDeferredDrawElementInit3(session);
5250
+ return {
5251
+ success: false,
5252
+ error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
5253
+ };
5254
+ } catch (err) {
5255
+ return {
5256
+ success: false,
5257
+ error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
5258
+ err,
5259
+ scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
5260
+ )}`
5261
+ };
5262
+ } finally {
5263
+ if (scratchDir) rmSync4(scratchDir, { recursive: true, force: true });
5379
5264
  }
5380
5265
  }
5381
- async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
5382
- const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
5383
- const rangeFrames = rangeEnd - rangeStart;
5384
- const reportFrame = (fileIndex) => {
5385
- job.framesRendered = fileIndex + 1;
5386
- checkDiskProjection(fileIndex + 1);
5387
- reportFrameProgress(
5388
- job,
5389
- `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
5390
- Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
5391
- onProgress,
5392
- fileIndex + 1,
5393
- rangeFrames
5394
- );
5266
+ function failResult(outputPath, target, source, error) {
5267
+ return {
5268
+ success: false,
5269
+ outputPath,
5270
+ targetDurationSeconds: target,
5271
+ sourceDurationSeconds: source,
5272
+ operation: "copy",
5273
+ error
5395
5274
  };
5396
- if (session.workerEncodeEnabled) {
5397
- let prev = null;
5398
- const drainPrev = async () => {
5399
- if (!prev) return;
5400
- assertNotAborted();
5401
- const buf = await prev.encodeResult;
5402
- writeCapturedFrame(session, prev.fileIndex, buf);
5403
- reportFrame(prev.fileIndex);
5404
- };
5405
- for (let i = resumeFrom; i < rangeFrames; i++) {
5406
- assertNotAborted();
5407
- const absoluteIdx = rangeStart + i;
5408
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
5409
- const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
5410
- await drainPrev();
5411
- prev = { fileIndex: i, encodeResult };
5412
- }
5413
- await drainPrev();
5414
- } else {
5415
- for (let i = resumeFrom; i < rangeFrames; i++) {
5416
- assertNotAborted();
5417
- const absoluteIdx = rangeStart + i;
5418
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
5419
- await captureFrame(session, i, time);
5420
- reportFrame(i);
5421
- }
5275
+ }
5276
+ async function defaultProbeVideoFrameInfo(videoPath, signal) {
5277
+ const fastInfo = await runFfprobeJson(
5278
+ [
5279
+ "-v",
5280
+ "error",
5281
+ "-select_streams",
5282
+ "v:0",
5283
+ "-show_entries",
5284
+ "stream=nb_frames,r_frame_rate",
5285
+ "-of",
5286
+ "json",
5287
+ "--",
5288
+ videoPath
5289
+ ],
5290
+ signal
5291
+ );
5292
+ let stream = fastInfo.streams?.[0];
5293
+ const fastCount = Number(stream?.nb_frames);
5294
+ if (stream && Number.isFinite(fastCount) && fastCount > 0) {
5295
+ return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
5422
5296
  }
5423
- await verifyDiskDrawElementSamples(
5424
- session,
5425
- {
5426
- workerId: 0,
5427
- startFrame: rangeStart + resumeFrom,
5428
- endFrame: rangeEnd,
5429
- outputDir: framesDir,
5430
- outputFrameOffset: rangeStart
5431
- },
5432
- false
5297
+ const slowInfo = await runFfprobeJson(
5298
+ [
5299
+ "-v",
5300
+ "error",
5301
+ "-select_streams",
5302
+ "v:0",
5303
+ "-count_packets",
5304
+ "-show_entries",
5305
+ "stream=nb_read_packets,r_frame_rate",
5306
+ "-of",
5307
+ "json",
5308
+ "--",
5309
+ videoPath
5310
+ ],
5311
+ signal
5433
5312
  );
5434
- dedupPerfs.push(getCapturePerfSummary3(session));
5313
+ stream = slowInfo.streams?.[0];
5314
+ if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
5315
+ const slowCount = Number(stream.nb_read_packets);
5316
+ if (!Number.isFinite(slowCount) || slowCount <= 0) {
5317
+ throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
5318
+ }
5319
+ return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
5435
5320
  }
5436
-
5437
- // src/services/render/stages/captureHdrStage.ts
5438
- import {
5439
- closeCaptureSession as closeCaptureSession7,
5440
- cloneCaptureWarnings as cloneCaptureWarnings2,
5441
- createCaptureSession as createCaptureSession7,
5442
- initTransparentBackground as initTransparentBackground2,
5443
- initializeSession as initializeSession7,
5444
- spawnStreamingEncoder as spawnStreamingEncoder3
5445
- } from "@hyperframes/engine";
5446
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
5447
-
5448
- // src/services/hdrImageTransferCache.ts
5449
- import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
5450
- var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
5451
-
5452
- // src/services/render/stages/captureHdrSequentialLoop.ts
5453
- import {
5454
- TRANSITIONS,
5455
- crossfade
5456
- } from "@hyperframes/engine";
5457
-
5458
- // src/services/render/stages/captureHdrHybridLoop.ts
5459
- import {
5460
- TRANSITIONS as TRANSITIONS2,
5461
- closeCaptureSession as closeCaptureSession6,
5462
- createCaptureSession as createCaptureSession6,
5463
- createFrameReorderBuffer as createFrameReorderBuffer2,
5464
- crossfade as crossfade2,
5465
- initTransparentBackground,
5466
- initializeSession as initializeSession6
5467
- } from "@hyperframes/engine";
5468
-
5469
- // src/services/render/stages/encodeStage.ts
5470
- import {
5471
- copyFileSync as copyFileSync3,
5472
- existsSync as existsSync9,
5473
- mkdirSync as mkdirSync5,
5474
- readdirSync as readdirSync2,
5475
- readFileSync as readFileSync5,
5476
- rmSync as rmSync4,
5477
- statSync as statSync3,
5478
- writeFileSync as writeFileSync2
5479
- } from "node:fs";
5480
- import { dirname as dirname5, join as join12 } from "node:path";
5481
- import {
5482
- encodeFramesChunkedConcat,
5483
- encodeFramesFromDir,
5484
- formatFfmpegError,
5485
- MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
5486
- resolveConfig,
5487
- runFfmpeg as runFfmpeg3,
5488
- spawnStreamingEncoder as spawnStreamingEncoder4
5489
- } from "@hyperframes/engine";
5490
- import {
5491
- clearGifFramesBeforeNext,
5492
- gifClearsAfterLeavingFrameInPlace
5493
- } from "@hyperframes/core";
5494
-
5495
- // src/services/render/stages/gifEncodeArgs.ts
5496
- import { join as join11 } from "node:path";
5497
- function fpsToFfmpegArg2(fps) {
5498
- return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
5321
+ function parseFrameRate(rate) {
5322
+ const [n, d] = rate.split("/");
5323
+ const fpsNum = Number(n);
5324
+ const fpsDen = d === void 0 ? 1 : Number(d);
5325
+ if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
5326
+ throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
5327
+ }
5328
+ return { fpsNum, fpsDen };
5499
5329
  }
5500
- var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
5501
- var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
5502
- var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
5503
- function framesInput(input, fpsArg) {
5504
- return [
5505
- ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
5506
- "-framerate",
5507
- fpsArg,
5508
- ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
5509
- "-i",
5510
- join11(input.framesDir, input.framePattern)
5511
- ];
5330
+ async function defaultProbeAudioInfo(audioPath, signal) {
5331
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
5332
+ return {
5333
+ durationSeconds: metadata.durationSeconds,
5334
+ sampleRate: metadata.sampleRate,
5335
+ channels: metadata.channels,
5336
+ audioCodec: metadata.audioCodec
5337
+ };
5512
5338
  }
5513
- function buildGifPalettegenArgs(input) {
5514
- const fpsArg = fpsToFfmpegArg2(input.fps);
5515
- const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
5516
- return [
5517
- "-y",
5518
- ...framesInput(input, fpsArg),
5519
- "-vf",
5520
- `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
5521
- input.palettePath
5522
- ];
5339
+ async function defaultRunFfmpeg(args, timeoutMs, signal) {
5340
+ return toStepResult(await runFfmpeg4(args, { signal, timeout: timeoutMs }), timeoutMs);
5523
5341
  }
5524
- function buildGifPaletteuseArgs(input) {
5525
- const fpsArg = fpsToFfmpegArg2(input.fps);
5526
- const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
5527
- return [
5528
- "-y",
5529
- ...framesInput(input, fpsArg),
5530
- "-i",
5531
- input.palettePath,
5532
- "-lavfi",
5533
- `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
5534
- "-loop",
5535
- String(input.loop),
5536
- ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
5537
- input.outputPath
5538
- ];
5342
+ function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
5343
+ return async (inputPath, outputPath, durationSeconds, filter) => {
5344
+ const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
5345
+ return toStepResult(
5346
+ await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
5347
+ timeoutMs
5348
+ );
5349
+ };
5350
+ }
5351
+ function toStepResult(result, timeoutMs) {
5352
+ if (result.success) return { success: true };
5353
+ return {
5354
+ success: false,
5355
+ error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
5356
+ failureReason: result.failureReason
5357
+ };
5358
+ }
5359
+ async function runFfprobeJson(args, signal) {
5360
+ if (!args.includes("--")) {
5361
+ throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
5362
+ }
5363
+ const proc = spawn(getFfprobeBinary(), args, {
5364
+ stdio: ["ignore", "pipe", "pipe"],
5365
+ windowsHide: true
5366
+ });
5367
+ trackChildProcess(proc);
5368
+ let stdout = "";
5369
+ proc.stdout.on("data", (data) => {
5370
+ stdout += data.toString();
5371
+ });
5372
+ const managed = new ManagedChildProcess(proc, {
5373
+ signal,
5374
+ deadlineAtMs: Date.now() + 3e4
5375
+ });
5376
+ const outcome = await managed.wait();
5377
+ if (outcome.reason === "spawn_error") {
5378
+ if (outcome.error?.code === "ENOENT") {
5379
+ throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
5380
+ }
5381
+ throw outcome.error ?? new Error(outcome.stderr);
5382
+ }
5383
+ if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
5384
+ const probed = args[args.length - 1];
5385
+ const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
5386
+ throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
5387
+ }
5388
+ try {
5389
+ return JSON.parse(stdout);
5390
+ } catch (err) {
5391
+ throw new Error(`Failed to parse ffprobe output: ${err.message}`);
5392
+ }
5539
5393
  }
5540
5394
 
5541
- // src/services/render/stages/encodeStage.ts
5542
- import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
5543
- function resolveGifLoop(loop) {
5544
- const resolved = loop ?? 0;
5545
- if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
5546
- throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
5395
+ // src/services/render/stages/audioStage.ts
5396
+ function assertAudioNotInterrupted(failures) {
5397
+ const interrupted = failures?.find((failure) => failure.reason === "external_interruption");
5398
+ if (!interrupted) return;
5399
+ throw encoderFailureError("Audio processing failed", {
5400
+ error: interrupted.detail,
5401
+ failureReason: "external_interruption",
5402
+ audioFailures: audioFailureDiagnostics([
5403
+ interrupted,
5404
+ ...(failures ?? []).filter((failure) => failure !== interrupted)
5405
+ ])
5406
+ });
5407
+ }
5408
+ async function runAudioStage(input) {
5409
+ const stageStart = Date.now();
5410
+ const pcmSourcePath = join10(input.workDir, "audio-source.wav");
5411
+ const duration = input.totalFrames * input.fps.den / input.fps.num;
5412
+ try {
5413
+ const result = await mixAudioForDelivery(input, duration, pcmSourcePath);
5414
+ let audioLoweredDb;
5415
+ if (result.hasAudio) {
5416
+ const peak = await constrainAacTruePeak({
5417
+ audioPath: result.audioOutputPath,
5418
+ sourcePath: pcmSourcePath,
5419
+ targetDurationSeconds: duration,
5420
+ signal: input.abortSignal,
5421
+ timeoutMs: input.ffmpegProcessTimeout
5422
+ });
5423
+ if (!peak.success) throw encoderFailureError("Audio true-peak check failed", peak);
5424
+ audioLoweredDb = peak.loweredDb;
5425
+ }
5426
+ return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
5427
+ } finally {
5428
+ rmSync5(pcmSourcePath, { force: true });
5547
5429
  }
5548
- return resolved;
5549
5430
  }
5550
- function gifEncodeFailure(startTime, outputPath, error, failureReason) {
5431
+ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
5432
+ const {
5433
+ projectDir,
5434
+ workDir,
5435
+ compiledDir,
5436
+ ffmpegProcessTimeout,
5437
+ audioGain,
5438
+ audios,
5439
+ masterFxChain,
5440
+ urlByAbsentLocalPath,
5441
+ abortSignal,
5442
+ assertNotAborted,
5443
+ log
5444
+ } = input;
5445
+ const audioOutputPath = join10(workDir, MIXED_AUDIO_FILENAME);
5446
+ let hasAudio = false;
5447
+ let audioError;
5448
+ let audioFailures;
5449
+ if (audios.length > 0) {
5450
+ let audioResult;
5451
+ try {
5452
+ audioResult = await processCompositionAudio(
5453
+ audios,
5454
+ projectDir,
5455
+ join10(workDir, "audio-work"),
5456
+ audioOutputPath,
5457
+ duration,
5458
+ abortSignal,
5459
+ {
5460
+ ffmpegProcessTimeout,
5461
+ audioGain,
5462
+ masterFxChain,
5463
+ urlByAbsentLocalPath,
5464
+ keepEncoderInputAt: pcmSourcePath
5465
+ },
5466
+ compiledDir
5467
+ );
5468
+ } catch (err) {
5469
+ assertNotAborted();
5470
+ const detail = err instanceof Error ? err.message : String(err);
5471
+ return {
5472
+ audioOutputPath,
5473
+ hasAudio: false,
5474
+ audioError: detail,
5475
+ // Synthesised rather than left undefined. The warning policy reads
5476
+ // owner, retryability, reason and stage off this list, so reporting the
5477
+ // FATAL failure — the one that took the whole mix down — with an empty
5478
+ // one gave it strictly less classification than a single dropped track
5479
+ // gets, which is the opposite of what this stage exists to do.
5480
+ // "internal" is the honest bucket: the stages enumerate ffmpeg steps
5481
+ // and this is the FX render, which is none of them.
5482
+ audioFailures: [
5483
+ {
5484
+ stage: "internal",
5485
+ reason: "internal",
5486
+ owner: "system",
5487
+ retryable: false,
5488
+ detail: detail.slice(0, 2e3)
5489
+ }
5490
+ ]
5491
+ };
5492
+ }
5493
+ assertNotAborted();
5494
+ assertAudioNotInterrupted(audioResult.failures);
5495
+ hasAudio = audioResult.success;
5496
+ audioFailures = audioResult.failures;
5497
+ if (!hasAudio) {
5498
+ audioError = audioResult.error ?? "audio mix failed for an unknown reason";
5499
+ for (const failure of audioResult.failures ?? []) {
5500
+ log?.warn("Audio track failed", {
5501
+ elementId: failure.elementId,
5502
+ stage: failure.stage,
5503
+ reason: failure.reason,
5504
+ owner: failure.owner,
5505
+ retryable: failure.retryable,
5506
+ detail: failure.detail
5507
+ });
5508
+ }
5509
+ }
5510
+ }
5551
5511
  return {
5552
- success: false,
5553
- outputPath,
5554
- durationMs: Date.now() - startTime,
5555
- framesEncoded: 0,
5556
- fileSize: 0,
5557
- error,
5558
- failureReason
5512
+ audioOutputPath,
5513
+ hasAudio,
5514
+ audioError,
5515
+ audioFailures
5559
5516
  };
5560
5517
  }
5561
- async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
5562
- const { outputPath } = argsInput;
5563
- const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
5564
- if (!result.success) {
5565
- const error = formatFfmpegError(result.exitCode, result.stderr);
5566
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
5567
- }
5568
- const gif = readFileSync5(outputPath);
5569
- if (!clearGifFramesBeforeNext(gif)) {
5570
- const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
5571
- return gifEncodeFailure(startTime, outputPath, error);
5518
+
5519
+ // src/services/render/stages/captureStage.ts
5520
+ import { existsSync as existsSync8, readdirSync, statfsSync as statfsSync2 } from "node:fs";
5521
+ import { join as join11 } from "node:path";
5522
+ import {
5523
+ classifyCaptureFailure as classifyCaptureFailure2,
5524
+ frameFileExtension,
5525
+ captureFrame,
5526
+ captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
5527
+ verifyDiskDrawElementSamples,
5528
+ writeCapturedFrame,
5529
+ closeCaptureSession as closeCaptureSession5,
5530
+ completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
5531
+ createCaptureSession as createCaptureSession5,
5532
+ getCapturePerfSummary as getCapturePerfSummary3,
5533
+ initializeSession as initializeSession5,
5534
+ prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
5535
+ } from "@hyperframes/engine";
5536
+ function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
5537
+ return workerCount > 1;
5538
+ }
5539
+ var DISK_PROJECTION_SAMPLE_FRAMES = 10;
5540
+ function freeDiskBytesAt(path) {
5541
+ try {
5542
+ const stat = statfsSync2(path);
5543
+ return stat.bavail * stat.bsize;
5544
+ } catch {
5545
+ return null;
5572
5546
  }
5573
- writeFileSync2(outputPath, gif);
5574
- return null;
5575
5547
  }
5576
- async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
5577
- const startTime = Date.now();
5578
- const files = readdirSync2(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
5579
- const frameCount = files.length;
5580
- if (frameCount === 0) {
5581
- return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
5548
+ function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
5549
+ const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
5550
+ if (workerCount > 1 && oneWorker.enabled) {
5551
+ 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.`;
5582
5552
  }
5583
- const argsInput = {
5584
- framesDir,
5585
- framePattern,
5586
- palettePath: input.palettePath,
5587
- outputPath,
5588
- fps: input.fps,
5589
- loop: input.loop,
5590
- preserveAlpha: input.preserveAlpha
5591
- };
5592
- try {
5593
- const run = { signal: input.signal, timeout: input.timeout };
5594
- for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
5595
- const result = await runFfmpeg3(args, run);
5596
- if (!result.success) {
5597
- const error = formatFfmpegError(result.exitCode, result.stderr);
5598
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
5599
- }
5600
- }
5601
- if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync5(outputPath)) !== false) {
5602
- const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
5603
- if (failure) return failure;
5604
- }
5605
- const fileSize = existsSync9(outputPath) ? statSync3(outputPath).size : 0;
5606
- return {
5607
- success: true,
5608
- outputPath,
5609
- durationMs: Date.now() - startTime,
5610
- framesEncoded: frameCount,
5611
- fileSize
5612
- };
5613
- } finally {
5614
- rmSync4(input.palettePath, { force: true });
5553
+ switch (oneWorker.reason) {
5554
+ case "format_excluded":
5555
+ return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
5556
+ case "disabled_by_config":
5557
+ return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
5558
+ case "duration_cap":
5559
+ 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.";
5560
+ default:
5561
+ return "Free up disk space.";
5615
5562
  }
5616
5563
  }
5617
- function startEncodeProgress(job, stage, onProgress) {
5618
- const total = job.totalFrames ?? 0;
5619
- updateJobStatus(job, "encoding", stage, 75, onProgress, {
5620
- code: "encode",
5621
- ...total > 0 && { done: 0, total }
5622
- });
5623
- return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
5564
+ function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
5565
+ const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
5566
+ return new Error(
5567
+ `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}`
5568
+ );
5624
5569
  }
5625
- function resolveEngineConfig(input) {
5626
- return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
5570
+ function measureCaptureFrameBytes(workDir, framesDir) {
5571
+ let total = sampleDirectoryBytes(framesDir);
5572
+ if (!existsSync8(workDir)) return total;
5573
+ for (const attempt of readdirSync(workDir)) {
5574
+ if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
5575
+ total += sampleDirectoryBytes(join11(workDir, attempt));
5576
+ continue;
5577
+ }
5578
+ if (!/^capture-attempt-\d+$/.test(attempt)) continue;
5579
+ const attemptDir = join11(workDir, attempt);
5580
+ for (const name of readdirSync(attemptDir)) {
5581
+ if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join11(attemptDir, name));
5582
+ }
5583
+ }
5584
+ return total;
5627
5585
  }
5628
- function videoEncoderOptions(input, engineCfg) {
5629
- const { job, preset } = input;
5630
- return {
5631
- fps: job.config.fps,
5632
- width: input.width,
5633
- height: input.height,
5634
- codec: preset.codec,
5635
- preset: preset.preset,
5636
- quality: input.effectiveQuality,
5637
- bitrate: input.effectiveBitrate,
5638
- pixelFormat: preset.pixelFormat,
5639
- vp9CpuUsed: engineCfg.vp9CpuUsed,
5640
- useGpu: job.config.useGpu,
5641
- hdr: preset.hdr,
5642
- // Distributed chunk renders pass these so the encoder writes closed-GOP
5643
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
5644
- // renders leave both undefined → preserves the existing open-GOP output.
5645
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
5646
- gopSize: input.gopSize
5586
+ function createDiskCaptureProjection(input) {
5587
+ const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
5588
+ let checked = false;
5589
+ return (capturedFrames) => {
5590
+ if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
5591
+ const measured = measureBytes();
5592
+ if (measured <= 0) return;
5593
+ checked = true;
5594
+ const remaining = totalFrames - capturedFrames;
5595
+ const freeBytes = freeDiskBytes(framesDir);
5596
+ if (remaining <= 0 || freeBytes === null) return;
5597
+ const needBytes = Math.ceil(measured / capturedFrames * remaining);
5598
+ if (needBytes > freeBytes * 0.9) {
5599
+ throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
5600
+ }
5647
5601
  };
5648
5602
  }
5649
- async function runEncodeStage(input) {
5603
+ async function runCaptureStage(input) {
5650
5604
  const {
5605
+ fileServer,
5606
+ workDir,
5607
+ framesDir,
5651
5608
  job,
5609
+ totalFrames,
5610
+ cfg,
5611
+ plan: plan2,
5652
5612
  log,
5653
- outputPath,
5654
- framesDir,
5655
- videoOnlyPath,
5656
- needsAlpha,
5657
- hasAudio,
5658
- audioOutputPath,
5659
- isPngSequence,
5660
- isGif,
5661
- enableChunkedEncode,
5662
- chunkedEncodeSize,
5613
+ captureAttempts,
5614
+ buildCaptureOptions,
5615
+ createRenderVideoFrameInjector,
5663
5616
  abortSignal,
5664
- assertNotAborted,
5665
- onProgress
5617
+ onProgress,
5618
+ frameRange,
5619
+ dedupPerfs
5666
5620
  } = input;
5667
- const stage5Start = Date.now();
5668
- if (isPngSequence) {
5669
- const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
5670
- if (!existsSync9(outputPath)) mkdirSync5(outputPath, { recursive: true });
5671
- const captured = readdirSync2(framesDir).filter((name) => name.endsWith(".png")).sort();
5672
- if (captured.length === 0) {
5621
+ let { probeSession } = input;
5622
+ let { workerCount } = plan2;
5623
+ const { forceScreenshot } = plan2;
5624
+ let lastBrowserConsole = [];
5625
+ let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
5626
+ const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
5627
+ if (frameRange !== void 0) {
5628
+ if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
5673
5629
  throw new Error(
5674
- `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
5630
+ `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
5675
5631
  );
5676
5632
  }
5677
- captured.forEach((name, i) => {
5678
- const dst = join12(outputPath, formatExportFrameName(i, "png"));
5679
- copyFileSync3(join12(framesDir, name), dst);
5680
- });
5681
- if (hasAudio && audioOutputPath && existsSync9(audioOutputPath)) {
5682
- copyFileSync3(audioOutputPath, join12(outputPath, MIXED_AUDIO_FILENAME2));
5683
- log.info(
5684
- `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
5633
+ const rangeFrames = frameRange.endFrame - frameRange.startFrame;
5634
+ if (rangeFrames !== totalFrames) {
5635
+ throw new Error(
5636
+ `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
5685
5637
  );
5686
5638
  }
5687
- encoded();
5688
- return { encodeMs: Date.now() - stage5Start };
5689
5639
  }
5690
- const engineCfg = resolveEngineConfig(input);
5691
- if (isGif) {
5692
- const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
5693
- if (hasAudio) {
5694
- log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
5695
- }
5696
- const frameExt2 = frameFileExtension2(input.captureImageFormat);
5697
- const framePattern2 = `frame_%06d.${frameExt2}`;
5698
- const loop = resolveGifLoop(job.config.gifLoop);
5699
- const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
5700
- fps: job.config.fps,
5701
- loop,
5702
- palettePath: join12(dirname5(videoOnlyPath), "gif-palette.png"),
5703
- preserveAlpha: needsAlpha,
5704
- signal: abortSignal,
5705
- timeout: engineCfg.ffmpegEncodeTimeout
5640
+ const captureOptions = buildCaptureOptions();
5641
+ const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
5642
+ const checkDiskProjection = createDiskCaptureProjection({
5643
+ framesDir,
5644
+ totalFrames,
5645
+ measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
5646
+ remedy
5647
+ });
5648
+ if (workerCount > 1) {
5649
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
5650
+ serverUrl: fileServer.url,
5651
+ workDir,
5652
+ framesDir,
5653
+ totalFrames,
5654
+ initialWorkerCount: workerCount,
5655
+ allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
5656
+ frameExt: frameFileExtension(captureOptions.format),
5657
+ captureOptions,
5658
+ createBeforeCaptureHook: createRenderVideoFrameInjector,
5659
+ abortSignal,
5660
+ frameRangeStart: frameRange?.startFrame,
5661
+ dedupPerfs,
5662
+ onProgress: (progress) => {
5663
+ if (progress.latestWorkerPhase) {
5664
+ if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
5665
+ return;
5666
+ }
5667
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
5668
+ job.framesRendered = progress.capturedFrames;
5669
+ checkDiskProjection(progress.capturedFrames);
5670
+ const frameProgress = progress.capturedFrames / progress.totalFrames;
5671
+ reportFrameProgress(
5672
+ job,
5673
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
5674
+ Math.round(25 + frameProgress * 45),
5675
+ onProgress,
5676
+ progress.capturedFrames,
5677
+ progress.totalFrames
5678
+ );
5679
+ },
5680
+ cfg: captureCfg,
5681
+ log
5706
5682
  });
5707
- assertNotAborted();
5708
- if (!encodeResult2.success) {
5709
- throw encoderFailureError("Encoding failed", encodeResult2);
5683
+ captureAttempts.push(...attempts);
5684
+ const lastAttempt = attempts[attempts.length - 1];
5685
+ if (lastAttempt) {
5686
+ workerCount = lastAttempt.workers;
5710
5687
  }
5711
- encoded();
5712
- return { encodeMs: Date.now() - stage5Start };
5688
+ if (probeSession) {
5689
+ captureBeyondViewport = probeSession.options.captureBeyondViewport;
5690
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
5691
+ await closeCaptureSession5(probeSession);
5692
+ probeSession = null;
5693
+ }
5694
+ } else {
5695
+ const sequential = await runSequentialDiskCapture(
5696
+ input,
5697
+ captureOptions,
5698
+ captureCfg,
5699
+ checkDiskProjection
5700
+ );
5701
+ probeSession = null;
5702
+ lastBrowserConsole = sequential.lastBrowserConsole;
5703
+ captureBeyondViewport = sequential.captureBeyondViewport;
5713
5704
  }
5714
- const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
5715
- const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
5716
- const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
5717
- const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
5718
- const frameExt = frameFileExtension2(input.captureImageFormat);
5719
- const framePattern = `frame_%06d.${frameExt}`;
5720
- const encoderOpts = videoEncoderOptions(input, engineCfg);
5721
- const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
5722
- framesDir,
5723
- framePattern,
5724
- videoOnlyPath,
5725
- encoderOpts,
5726
- chunkedEncodeSize,
5727
- abortSignal,
5728
- videoEngineCfg,
5729
- onFramesEncoded
5730
- ) : await encodeFramesFromDir(
5705
+ return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
5706
+ }
5707
+ async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
5708
+ const {
5709
+ fileServer,
5731
5710
  framesDir,
5732
- framePattern,
5733
- videoOnlyPath,
5734
- encoderOpts,
5711
+ totalFrames,
5712
+ log,
5713
+ createRenderVideoFrameInjector,
5735
5714
  abortSignal,
5736
- videoEngineCfg,
5737
- onFramesEncoded
5738
- );
5739
- assertNotAborted();
5740
- if (!encodeResult.success) {
5741
- throw encoderFailureError("Encoding failed", encodeResult);
5742
- }
5743
- onFramesEncoded();
5744
- return { encodeMs: Date.now() - stage5Start };
5745
- }
5746
-
5747
- // src/services/render/stages/assembleStage.ts
5748
- import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
5749
-
5750
- // src/services/render/audioPadTrim.ts
5751
- import { spawn } from "node:child_process";
5752
- import { mkdtempSync as mkdtempSync2, renameSync as renameSync2, rmSync as rmSync5 } from "node:fs";
5753
- import { dirname as dirname6, join as join13 } from "node:path";
5754
- import {
5755
- DEFAULT_CONFIG,
5756
- describeFfmpegFailure as describeFfmpegFailure2,
5757
- encodeAac,
5758
- extractAudioMetadata as extractAudioMetadata3,
5759
- getFfprobeBinary,
5760
- ManagedChildProcess,
5761
- runFfmpeg as runFfmpeg5,
5762
- trackChildProcess
5763
- } from "@hyperframes/engine";
5764
- import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
5765
-
5766
- // src/services/render/audioTruePeak.ts
5767
- import {
5768
- aacSegmentCount,
5769
- describeFfmpegFailure,
5770
- extractAudioMetadata as extractAudioMetadata2,
5771
- runFfmpeg as runFfmpeg4
5772
- } from "@hyperframes/engine";
5773
- var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
5774
- function checkedDbfs(value) {
5775
- if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
5776
- throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
5777
- }
5778
- return value;
5779
- }
5780
- async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
5781
- let firstBlock;
5782
- let laterBlocks = Number.NEGATIVE_INFINITY;
5783
- let partialLine = "";
5784
- const readLines = (chunk) => {
5785
- const lines = (partialLine + chunk).split(/\r|\n/);
5786
- partialLine = lines.pop() ?? "";
5787
- for (const line of lines) {
5788
- const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
5789
- if (!ftpk) continue;
5790
- const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
5791
- if (firstBlock === void 0) firstBlock = block;
5792
- else laterBlocks = Math.max(laterBlocks, block);
5715
+ assertNotAborted,
5716
+ frameRange
5717
+ } = input;
5718
+ let { probeSession } = input;
5719
+ let lastBrowserConsole = [];
5720
+ let captureBeyondViewport;
5721
+ const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
5722
+ const rangeFrames = rangeEnd - rangeStart;
5723
+ const frameExt = frameFileExtension(captureOptions.format);
5724
+ let transientRetriesUsed = 0;
5725
+ let resumeFrom = 0;
5726
+ while (true) {
5727
+ const videoInjector = createRenderVideoFrameInjector();
5728
+ const session = probeSession ?? await createCaptureSession5(
5729
+ fileServer.url,
5730
+ framesDir,
5731
+ captureOptions,
5732
+ videoInjector,
5733
+ captureCfg
5734
+ );
5735
+ captureBeyondViewport = session.options.captureBeyondViewport;
5736
+ try {
5737
+ if (probeSession) {
5738
+ prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
5739
+ probeSession = null;
5740
+ }
5741
+ await ensureSessionReady(session);
5742
+ assertNotAborted();
5743
+ lastBrowserConsole = session.browserConsoleBuffer;
5744
+ await captureSessionFrames(session, {
5745
+ input,
5746
+ rangeStart,
5747
+ rangeEnd,
5748
+ resumeFrom,
5749
+ checkDiskProjection
5750
+ });
5751
+ break;
5752
+ } catch (error) {
5753
+ lastBrowserConsole = session.browserConsoleBuffer;
5754
+ const resume = planTransientResume(error, {
5755
+ abortSignal,
5756
+ rangeFrames,
5757
+ framesDir,
5758
+ frameExt,
5759
+ retriesUsed: transientRetriesUsed
5760
+ });
5761
+ if (resume !== null) {
5762
+ transientRetriesUsed++;
5763
+ resumeFrom = resume;
5764
+ log.warn(
5765
+ "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
5766
+ {
5767
+ resumeFrom,
5768
+ rangeFrames,
5769
+ transientRetriesUsed,
5770
+ error: error instanceof Error ? error.message : String(error)
5771
+ }
5772
+ );
5773
+ continue;
5774
+ }
5775
+ throw wrapCaptureStageError(error, lastBrowserConsole);
5776
+ } finally {
5777
+ lastBrowserConsole = session.browserConsoleBuffer;
5778
+ await closeCaptureSession5(session);
5793
5779
  }
5794
- };
5795
- const result = await runFfmpeg4(
5796
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
5797
- { signal, timeout: timeoutMs, onStderr: readLines }
5798
- );
5799
- if (!result.success) {
5800
- throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
5801
5780
  }
5802
- const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
5803
- return {
5804
- peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
5805
- firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
5806
- laterBlocks: checkedDbfs(laterBlocks)
5807
- };
5781
+ return { lastBrowserConsole, captureBeyondViewport };
5808
5782
  }
5809
- async function measureTruePeakDbfs(audioPath, options) {
5810
- const { timeoutMs, signal } = options;
5811
- const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
5812
- const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
5813
- const block = 4800;
5814
- const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
5815
- if (count === 1) return wholeFile();
5816
- const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
5817
- const peers = new AbortController();
5818
- const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
5819
- const scans = await Promise.all(
5820
- Array.from({ length: count }, (_, k) => {
5821
- const start = k === 0 ? 0 : cut(k) - block;
5822
- const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
5823
- return scanTruePeak(
5824
- audioPath,
5825
- `atrim=start_sample=${start}${end},`,
5826
- timeoutMs,
5827
- segmentSignal
5828
- );
5829
- })
5830
- ).catch((error) => {
5831
- peers.abort();
5832
- throw error;
5833
- });
5834
- const last = scans[count - 1];
5835
- const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
5836
- if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
5837
- return wholeFile();
5783
+ function resolveCaptureRange(frameRange, totalFrames) {
5784
+ return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
5838
5785
  }
5839
-
5840
- // src/services/render/audioPadTrim.ts
5841
- var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
5842
- var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
5843
- var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
5844
- var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
5845
- function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
5846
- return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
5786
+ function planTransientResume(error, ctx) {
5787
+ const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
5788
+ const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
5789
+ const [firstMissing] = missing;
5790
+ if (!firstMissing) return null;
5791
+ return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
5847
5792
  }
5848
- function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
5849
- const delta = targetDurationSeconds - sourceDurationSeconds;
5850
- const targetSec = formatSeconds(targetDurationSeconds);
5851
- if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
5852
- return {
5853
- operation: "copy",
5854
- steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
5855
- cleanupPaths: []
5856
- };
5793
+ async function ensureSessionReady(session) {
5794
+ if (!session.isInitialized) {
5795
+ await initializeSession5(session);
5796
+ return;
5857
5797
  }
5858
- if (delta > 0) {
5859
- return {
5860
- operation: "pad",
5861
- steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5862
- cleanupPaths: []
5863
- };
5798
+ if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
5799
+ await completeDeferredDrawElementInit3(session);
5864
5800
  }
5865
- return {
5866
- operation: "trim",
5867
- steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5868
- cleanupPaths: []
5869
- };
5870
- }
5871
- function formatSeconds(sec) {
5872
- return sec.toFixed(6);
5873
- }
5874
- function sanitizeProbeFailure(reason, paths) {
5875
- const message = reason instanceof Error ? reason.message : String(reason);
5876
- const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
5877
- return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
5878
5801
  }
5879
- async function padOrTrimAudioToVideoFrameCount(input) {
5880
- const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
5881
- const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
5882
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5883
- const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
5884
- const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
5885
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
5886
- const [videoResult, audioResult] = await Promise.allSettled([
5887
- probeVideo(input.videoPath),
5888
- probeAudio(input.audioPath, input.signal)
5889
- ]);
5890
- const probePaths = [input.videoPath, input.audioPath, input.outputPath];
5891
- if (videoResult.status === "rejected") {
5892
- return failResult(
5893
- input.outputPath,
5894
- 0,
5895
- audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
5896
- `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
5897
- videoResult.reason,
5898
- probePaths
5899
- )}`
5900
- );
5901
- }
5902
- if (audioResult.status === "rejected") {
5903
- return failResult(
5904
- input.outputPath,
5905
- 0,
5906
- 0,
5907
- `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
5908
- audioResult.reason,
5909
- probePaths
5910
- )}`
5911
- );
5912
- }
5913
- const videoInfo = videoResult.value;
5914
- const audioInfo = audioResult.value;
5915
- if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
5916
- return failResult(
5917
- input.outputPath,
5918
- 0,
5919
- audioInfo.durationSeconds,
5920
- `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
5802
+ async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
5803
+ const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
5804
+ const rangeFrames = rangeEnd - rangeStart;
5805
+ const reportFrame = (fileIndex) => {
5806
+ job.framesRendered = fileIndex + 1;
5807
+ checkDiskProjection(fileIndex + 1);
5808
+ reportFrameProgress(
5809
+ job,
5810
+ `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
5811
+ Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
5812
+ onProgress,
5813
+ fileIndex + 1,
5814
+ rangeFrames
5921
5815
  );
5922
- }
5923
- const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
5924
- const plan2 = buildPadTrimAudioPlan(
5925
- input.audioPath,
5926
- input.outputPath,
5927
- audioInfo.durationSeconds,
5928
- targetDurationSeconds
5929
- );
5930
- try {
5931
- for (const step of plan2.steps) {
5932
- const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
5933
- if (!ffmpegResult.success) {
5934
- return {
5935
- success: false,
5936
- outputPath: input.outputPath,
5937
- targetDurationSeconds,
5938
- sourceDurationSeconds: audioInfo.durationSeconds,
5939
- operation: plan2.operation,
5940
- error: ffmpegResult.error,
5941
- failureReason: ffmpegResult.failureReason
5942
- };
5943
- }
5944
- }
5945
- } catch (err) {
5946
- return {
5947
- success: false,
5948
- outputPath: input.outputPath,
5949
- targetDurationSeconds,
5950
- sourceDurationSeconds: audioInfo.durationSeconds,
5951
- operation: plan2.operation,
5952
- error: `audioPadTrim: failed to materialize ${plan2.operation}: ${err instanceof Error ? err.message : String(err)}`
5816
+ };
5817
+ if (session.workerEncodeEnabled) {
5818
+ let prev = null;
5819
+ const drainPrev = async () => {
5820
+ if (!prev) return;
5821
+ assertNotAborted();
5822
+ const buf = await prev.encodeResult;
5823
+ writeCapturedFrame(session, prev.fileIndex, buf);
5824
+ reportFrame(prev.fileIndex);
5953
5825
  };
5954
- } finally {
5955
- for (const path of plan2.cleanupPaths) rmSync5(path, { force: true });
5956
- }
5957
- let loweredDb = 0;
5958
- if (probeTruePeak) {
5959
- const correction = await enforceAacTruePeak({
5960
- audioPath: input.outputPath,
5961
- targetDurationSeconds,
5962
- signal: input.signal,
5963
- probeTruePeak,
5964
- encode
5965
- });
5966
- if (!correction.success) {
5967
- return {
5968
- success: false,
5969
- outputPath: input.outputPath,
5970
- targetDurationSeconds,
5971
- sourceDurationSeconds: audioInfo.durationSeconds,
5972
- operation: plan2.operation,
5973
- error: correction.error,
5974
- failureReason: correction.failureReason
5975
- };
5826
+ for (let i = resumeFrom; i < rangeFrames; i++) {
5827
+ assertNotAborted();
5828
+ const absoluteIdx = rangeStart + i;
5829
+ const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
5830
+ const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
5831
+ await drainPrev();
5832
+ prev = { fileIndex: i, encodeResult };
5833
+ }
5834
+ await drainPrev();
5835
+ } else {
5836
+ for (let i = resumeFrom; i < rangeFrames; i++) {
5837
+ assertNotAborted();
5838
+ const absoluteIdx = rangeStart + i;
5839
+ const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
5840
+ await captureFrame(session, i, time);
5841
+ reportFrame(i);
5976
5842
  }
5977
- loweredDb = correction.loweredDb ?? 0;
5978
5843
  }
5979
- return {
5980
- success: true,
5981
- outputPath: input.outputPath,
5982
- targetDurationSeconds,
5983
- sourceDurationSeconds: audioInfo.durationSeconds,
5984
- operation: plan2.operation,
5985
- ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
5986
- };
5844
+ await verifyDiskDrawElementSamples(
5845
+ session,
5846
+ {
5847
+ workerId: 0,
5848
+ startFrame: rangeStart + resumeFrom,
5849
+ endFrame: rangeEnd,
5850
+ outputDir: framesDir,
5851
+ outputFrameOffset: rangeStart
5852
+ },
5853
+ false
5854
+ );
5855
+ dedupPerfs.push(getCapturePerfSummary3(session));
5987
5856
  }
5988
- async function constrainAacTruePeak(input) {
5989
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5990
- return enforceAacTruePeak({
5991
- audioPath: input.audioPath,
5992
- targetDurationSeconds: input.targetDurationSeconds,
5993
- signal: input.signal,
5994
- probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
5995
- encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
5996
- });
5857
+
5858
+ // src/services/render/stages/captureHdrStage.ts
5859
+ import {
5860
+ closeCaptureSession as closeCaptureSession7,
5861
+ cloneCaptureWarnings as cloneCaptureWarnings2,
5862
+ createCaptureSession as createCaptureSession7,
5863
+ initTransparentBackground as initTransparentBackground2,
5864
+ initializeSession as initializeSession7,
5865
+ spawnStreamingEncoder as spawnStreamingEncoder3
5866
+ } from "@hyperframes/engine";
5867
+ import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
5868
+
5869
+ // src/services/hdrImageTransferCache.ts
5870
+ import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
5871
+ var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
5872
+
5873
+ // src/services/render/stages/captureHdrSequentialLoop.ts
5874
+ import {
5875
+ TRANSITIONS,
5876
+ crossfade
5877
+ } from "@hyperframes/engine";
5878
+
5879
+ // src/services/render/stages/captureHdrHybridLoop.ts
5880
+ import {
5881
+ TRANSITIONS as TRANSITIONS2,
5882
+ closeCaptureSession as closeCaptureSession6,
5883
+ createCaptureSession as createCaptureSession6,
5884
+ createFrameReorderBuffer as createFrameReorderBuffer2,
5885
+ crossfade as crossfade2,
5886
+ initTransparentBackground,
5887
+ initializeSession as initializeSession6
5888
+ } from "@hyperframes/engine";
5889
+
5890
+ // src/services/render/stages/encodeStage.ts
5891
+ import {
5892
+ copyFileSync as copyFileSync3,
5893
+ existsSync as existsSync9,
5894
+ mkdirSync as mkdirSync5,
5895
+ readdirSync as readdirSync2,
5896
+ readFileSync as readFileSync5,
5897
+ rmSync as rmSync6,
5898
+ statSync as statSync3,
5899
+ writeFileSync as writeFileSync2
5900
+ } from "node:fs";
5901
+ import { dirname as dirname6, join as join12 } from "node:path";
5902
+ import {
5903
+ encodeFramesChunkedConcat,
5904
+ encodeFramesFromDir,
5905
+ formatFfmpegError,
5906
+ MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
5907
+ resolveConfig,
5908
+ runFfmpeg as runFfmpeg5,
5909
+ spawnStreamingEncoder as spawnStreamingEncoder4
5910
+ } from "@hyperframes/engine";
5911
+ import {
5912
+ clearGifFramesBeforeNext,
5913
+ gifClearsAfterLeavingFrameInPlace
5914
+ } from "@hyperframes/core";
5915
+
5916
+ // src/services/render/stages/gifEncodeArgs.ts
5917
+ import { frameSequencePath } from "@hyperframes/engine";
5918
+ function fpsToFfmpegArg2(fps) {
5919
+ return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
5997
5920
  }
5998
- async function enforceAacTruePeak(input) {
5999
- let scratchDir;
6000
- try {
6001
- scratchDir = mkdtempSync2(join13(dirname6(input.audioPath), ".true-peak-"));
6002
- const correctedPath = join13(scratchDir, "audio.m4a");
6003
- const measurements = [];
6004
- let attenuationDb = 0;
6005
- let measuredPath = input.audioPath;
6006
- for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
6007
- const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
6008
- measurements.push(
6009
- `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
6010
- );
6011
- if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
6012
- return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
6013
- }
6014
- if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
6015
- if (measuredPath !== correctedPath) return { success: true };
6016
- renameSync2(correctedPath, input.audioPath);
6017
- return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
6018
- }
6019
- if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
6020
- const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
6021
- attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
6022
- const result = await input.encode(
6023
- input.audioPath,
6024
- correctedPath,
6025
- input.targetDurationSeconds,
6026
- aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
6027
- );
6028
- if (!result.success) {
6029
- return {
6030
- success: false,
6031
- error: sanitizeProbeFailure(
6032
- result.error ?? "audioPadTrim: failed to constrain AAC true peak",
6033
- [input.audioPath, correctedPath]
6034
- ),
6035
- failureReason: result.failureReason
6036
- };
6037
- }
6038
- measuredPath = correctedPath;
6039
- }
6040
- return {
6041
- success: false,
6042
- error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
6043
- };
6044
- } catch (err) {
6045
- return {
6046
- success: false,
6047
- error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
6048
- err,
6049
- scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
6050
- )}`
6051
- };
6052
- } finally {
6053
- if (scratchDir) rmSync5(scratchDir, { recursive: true, force: true });
5921
+ var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
5922
+ var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
5923
+ var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
5924
+ function framesInput(input, fpsArg) {
5925
+ return [
5926
+ ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
5927
+ "-framerate",
5928
+ fpsArg,
5929
+ ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
5930
+ "-i",
5931
+ frameSequencePath(input.framesDir, input.framePattern)
5932
+ ];
5933
+ }
5934
+ function buildGifPalettegenArgs(input) {
5935
+ const fpsArg = fpsToFfmpegArg2(input.fps);
5936
+ const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
5937
+ return [
5938
+ "-y",
5939
+ ...framesInput(input, fpsArg),
5940
+ "-vf",
5941
+ `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
5942
+ input.palettePath
5943
+ ];
5944
+ }
5945
+ function buildGifPaletteuseArgs(input) {
5946
+ const fpsArg = fpsToFfmpegArg2(input.fps);
5947
+ const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
5948
+ return [
5949
+ "-y",
5950
+ ...framesInput(input, fpsArg),
5951
+ "-i",
5952
+ input.palettePath,
5953
+ "-lavfi",
5954
+ `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
5955
+ "-loop",
5956
+ String(input.loop),
5957
+ ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
5958
+ input.outputPath
5959
+ ];
5960
+ }
5961
+
5962
+ // src/services/render/stages/encodeStage.ts
5963
+ import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
5964
+ function resolveGifLoop(loop) {
5965
+ const resolved = loop ?? 0;
5966
+ if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
5967
+ throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
6054
5968
  }
5969
+ return resolved;
6055
5970
  }
6056
- function failResult(outputPath, target, source, error) {
5971
+ function gifEncodeFailure(startTime, outputPath, error, failureReason) {
6057
5972
  return {
6058
5973
  success: false,
6059
5974
  outputPath,
6060
- targetDurationSeconds: target,
6061
- sourceDurationSeconds: source,
6062
- operation: "copy",
6063
- error
5975
+ durationMs: Date.now() - startTime,
5976
+ framesEncoded: 0,
5977
+ fileSize: 0,
5978
+ error,
5979
+ failureReason
6064
5980
  };
6065
5981
  }
6066
- async function defaultProbeVideoFrameInfo(videoPath, signal) {
6067
- const fastInfo = await runFfprobeJson(
6068
- [
6069
- "-v",
6070
- "error",
6071
- "-select_streams",
6072
- "v:0",
6073
- "-show_entries",
6074
- "stream=nb_frames,r_frame_rate",
6075
- "-of",
6076
- "json",
6077
- "--",
6078
- videoPath
6079
- ],
6080
- signal
6081
- );
6082
- let stream = fastInfo.streams?.[0];
6083
- const fastCount = Number(stream?.nb_frames);
6084
- if (stream && Number.isFinite(fastCount) && fastCount > 0) {
6085
- return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
6086
- }
6087
- const slowInfo = await runFfprobeJson(
6088
- [
6089
- "-v",
6090
- "error",
6091
- "-select_streams",
6092
- "v:0",
6093
- "-count_packets",
6094
- "-show_entries",
6095
- "stream=nb_read_packets,r_frame_rate",
6096
- "-of",
6097
- "json",
6098
- "--",
6099
- videoPath
6100
- ],
6101
- signal
6102
- );
6103
- stream = slowInfo.streams?.[0];
6104
- if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
6105
- const slowCount = Number(stream.nb_read_packets);
6106
- if (!Number.isFinite(slowCount) || slowCount <= 0) {
6107
- throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
5982
+ async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
5983
+ const { outputPath } = argsInput;
5984
+ const result = await runFfmpeg5(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
5985
+ if (!result.success) {
5986
+ const error = formatFfmpegError(result.exitCode, result.stderr);
5987
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
6108
5988
  }
6109
- return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
6110
- }
6111
- function parseFrameRate(rate) {
6112
- const [n, d] = rate.split("/");
6113
- const fpsNum = Number(n);
6114
- const fpsDen = d === void 0 ? 1 : Number(d);
6115
- if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
6116
- throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
5989
+ const gif = readFileSync5(outputPath);
5990
+ if (!clearGifFramesBeforeNext(gif)) {
5991
+ const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
5992
+ return gifEncodeFailure(startTime, outputPath, error);
6117
5993
  }
6118
- return { fpsNum, fpsDen };
5994
+ writeFileSync2(outputPath, gif);
5995
+ return null;
6119
5996
  }
6120
- async function defaultProbeAudioInfo(audioPath, signal) {
6121
- const metadata = await extractAudioMetadata3(audioPath, { signal });
6122
- return {
6123
- durationSeconds: metadata.durationSeconds,
6124
- sampleRate: metadata.sampleRate,
6125
- channels: metadata.channels,
6126
- audioCodec: metadata.audioCodec
5997
+ async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
5998
+ const startTime = Date.now();
5999
+ const files = readdirSync2(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
6000
+ const frameCount = files.length;
6001
+ if (frameCount === 0) {
6002
+ return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
6003
+ }
6004
+ const argsInput = {
6005
+ framesDir,
6006
+ framePattern,
6007
+ palettePath: input.palettePath,
6008
+ outputPath,
6009
+ fps: input.fps,
6010
+ loop: input.loop,
6011
+ preserveAlpha: input.preserveAlpha
6127
6012
  };
6013
+ try {
6014
+ const run = { signal: input.signal, timeout: input.timeout };
6015
+ for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
6016
+ const result = await runFfmpeg5(args, run);
6017
+ if (!result.success) {
6018
+ const error = formatFfmpegError(result.exitCode, result.stderr);
6019
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
6020
+ }
6021
+ }
6022
+ if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync5(outputPath)) !== false) {
6023
+ const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
6024
+ if (failure) return failure;
6025
+ }
6026
+ const fileSize = existsSync9(outputPath) ? statSync3(outputPath).size : 0;
6027
+ return {
6028
+ success: true,
6029
+ outputPath,
6030
+ durationMs: Date.now() - startTime,
6031
+ framesEncoded: frameCount,
6032
+ fileSize
6033
+ };
6034
+ } finally {
6035
+ rmSync6(input.palettePath, { force: true });
6036
+ }
6128
6037
  }
6129
- async function defaultRunFfmpeg(args, timeoutMs, signal) {
6130
- return toStepResult(await runFfmpeg5(args, { signal, timeout: timeoutMs }), timeoutMs);
6038
+ function startEncodeProgress(job, stage, onProgress) {
6039
+ const total = job.totalFrames ?? 0;
6040
+ updateJobStatus(job, "encoding", stage, 75, onProgress, {
6041
+ code: "encode",
6042
+ ...total > 0 && { done: 0, total }
6043
+ });
6044
+ return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
6131
6045
  }
6132
- function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
6133
- return async (inputPath, outputPath, durationSeconds, filter) => {
6134
- const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
6135
- return toStepResult(
6136
- await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
6137
- timeoutMs
6138
- );
6139
- };
6046
+ function resolveEngineConfig(input) {
6047
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
6140
6048
  }
6141
- function toStepResult(result, timeoutMs) {
6142
- if (result.success) return { success: true };
6049
+ function videoEncoderOptions(input, engineCfg) {
6050
+ const { job, preset } = input;
6143
6051
  return {
6144
- success: false,
6145
- error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
6146
- failureReason: result.failureReason
6052
+ fps: job.config.fps,
6053
+ width: input.width,
6054
+ height: input.height,
6055
+ codec: preset.codec,
6056
+ preset: preset.preset,
6057
+ quality: input.effectiveQuality,
6058
+ bitrate: input.effectiveBitrate,
6059
+ pixelFormat: preset.pixelFormat,
6060
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
6061
+ useGpu: job.config.useGpu,
6062
+ hdr: preset.hdr,
6063
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
6064
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
6065
+ // renders leave both undefined → preserves the existing open-GOP output.
6066
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
6067
+ gopSize: input.gopSize
6147
6068
  };
6148
6069
  }
6149
- async function runFfprobeJson(args, signal) {
6150
- if (!args.includes("--")) {
6151
- throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
6152
- }
6153
- const proc = spawn(getFfprobeBinary(), args, {
6154
- stdio: ["ignore", "pipe", "pipe"],
6155
- windowsHide: true
6156
- });
6157
- trackChildProcess(proc);
6158
- let stdout = "";
6159
- proc.stdout.on("data", (data) => {
6160
- stdout += data.toString();
6161
- });
6162
- const managed = new ManagedChildProcess(proc, {
6163
- signal,
6164
- deadlineAtMs: Date.now() + 3e4
6165
- });
6166
- const outcome = await managed.wait();
6167
- if (outcome.reason === "spawn_error") {
6168
- if (outcome.error?.code === "ENOENT") {
6169
- throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
6070
+ async function runEncodeStage(input) {
6071
+ const {
6072
+ job,
6073
+ log,
6074
+ outputPath,
6075
+ framesDir,
6076
+ videoOnlyPath,
6077
+ needsAlpha,
6078
+ hasAudio,
6079
+ audioOutputPath,
6080
+ isPngSequence,
6081
+ isGif,
6082
+ enableChunkedEncode,
6083
+ chunkedEncodeSize,
6084
+ abortSignal,
6085
+ assertNotAborted,
6086
+ onProgress
6087
+ } = input;
6088
+ const stage5Start = Date.now();
6089
+ if (isPngSequence) {
6090
+ const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
6091
+ if (!existsSync9(outputPath)) mkdirSync5(outputPath, { recursive: true });
6092
+ const captured = readdirSync2(framesDir).filter((name) => name.endsWith(".png")).sort();
6093
+ if (captured.length === 0) {
6094
+ throw new Error(
6095
+ `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
6096
+ );
6170
6097
  }
6171
- throw outcome.error ?? new Error(outcome.stderr);
6098
+ captured.forEach((name, i) => {
6099
+ const dst = join12(outputPath, formatExportFrameName(i, "png"));
6100
+ copyFileSync3(join12(framesDir, name), dst);
6101
+ });
6102
+ if (hasAudio && audioOutputPath && existsSync9(audioOutputPath)) {
6103
+ copyFileSync3(audioOutputPath, join12(outputPath, MIXED_AUDIO_FILENAME2));
6104
+ log.info(
6105
+ `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
6106
+ );
6107
+ }
6108
+ encoded();
6109
+ return { encodeMs: Date.now() - stage5Start };
6172
6110
  }
6173
- if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
6174
- const probed = args[args.length - 1];
6175
- const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
6176
- throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
6111
+ const engineCfg = resolveEngineConfig(input);
6112
+ if (isGif) {
6113
+ const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
6114
+ if (hasAudio) {
6115
+ log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
6116
+ }
6117
+ const frameExt2 = frameFileExtension2(input.captureImageFormat);
6118
+ const framePattern2 = `frame_%06d.${frameExt2}`;
6119
+ const loop = resolveGifLoop(job.config.gifLoop);
6120
+ const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
6121
+ fps: job.config.fps,
6122
+ loop,
6123
+ palettePath: join12(dirname6(videoOnlyPath), "gif-palette.png"),
6124
+ preserveAlpha: needsAlpha,
6125
+ signal: abortSignal,
6126
+ timeout: engineCfg.ffmpegEncodeTimeout
6127
+ });
6128
+ assertNotAborted();
6129
+ if (!encodeResult2.success) {
6130
+ throw encoderFailureError("Encoding failed", encodeResult2);
6131
+ }
6132
+ encoded();
6133
+ return { encodeMs: Date.now() - stage5Start };
6177
6134
  }
6178
- try {
6179
- return JSON.parse(stdout);
6180
- } catch (err) {
6181
- throw new Error(`Failed to parse ffprobe output: ${err.message}`);
6135
+ const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
6136
+ const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
6137
+ const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
6138
+ const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
6139
+ const frameExt = frameFileExtension2(input.captureImageFormat);
6140
+ const framePattern = `frame_%06d.${frameExt}`;
6141
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
6142
+ const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
6143
+ framesDir,
6144
+ framePattern,
6145
+ videoOnlyPath,
6146
+ encoderOpts,
6147
+ chunkedEncodeSize,
6148
+ abortSignal,
6149
+ videoEngineCfg,
6150
+ onFramesEncoded
6151
+ ) : await encodeFramesFromDir(
6152
+ framesDir,
6153
+ framePattern,
6154
+ videoOnlyPath,
6155
+ encoderOpts,
6156
+ abortSignal,
6157
+ videoEngineCfg,
6158
+ onFramesEncoded
6159
+ );
6160
+ assertNotAborted();
6161
+ if (!encodeResult.success) {
6162
+ throw encoderFailureError("Encoding failed", encodeResult);
6182
6163
  }
6164
+ onFramesEncoded();
6165
+ return { encodeMs: Date.now() - stage5Start };
6183
6166
  }
6184
6167
 
6168
+ // src/services/render/stages/assembleStage.ts
6169
+ import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
6170
+
6185
6171
  // src/services/renderOrchestrator.ts
6186
6172
  function sampleDirectoryBytes(dir) {
6187
6173
  let total = 0;
@@ -6196,7 +6182,7 @@ function sampleDirectoryBytes(dir) {
6196
6182
  continue;
6197
6183
  }
6198
6184
  for (const name of entries) {
6199
- const full = join14(current, name);
6185
+ const full = join13(current, name);
6200
6186
  try {
6201
6187
  const st = statSync4(full);
6202
6188
  if (st.isDirectory()) {
@@ -6281,7 +6267,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
6281
6267
  const ranges = [];
6282
6268
  let rangeStart = null;
6283
6269
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
6284
- const framePath = join14(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6270
+ const framePath = join13(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6285
6271
  const missing = !existsSync10(framePath) || statSync4(framePath).size <= 8;
6286
6272
  if (missing && rangeStart === null) {
6287
6273
  rangeStart = frameIndex;
@@ -6304,7 +6290,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
6304
6290
  workerId,
6305
6291
  startFrame: rangeStart + range.startFrame,
6306
6292
  endFrame: rangeStart + range.endFrame,
6307
- outputDir: join14(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
6293
+ outputDir: join13(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
6308
6294
  outputFrameOffset: rangeStart
6309
6295
  }));
6310
6296
  batches.push(batch);
@@ -6330,7 +6316,7 @@ function isRecoverableParallelCaptureError(error) {
6330
6316
  function countCapturedFrames(totalFrames, framesDir, frameExt) {
6331
6317
  let captured = 0;
6332
6318
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
6333
- const framePath = join14(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6319
+ const framePath = join13(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6334
6320
  if (existsSync10(framePath)) captured++;
6335
6321
  }
6336
6322
  return captured;
@@ -6356,7 +6342,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
6356
6342
  reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
6357
6343
  });
6358
6344
  pendingTransientRetry = false;
6359
- const attemptWorkDir = join14(options.workDir, `capture-attempt-${attempt}`);
6345
+ const attemptWorkDir = join13(options.workDir, `capture-attempt-${attempt}`);
6360
6346
  const batches = missingRanges ? buildMissingFrameRetryBatches(
6361
6347
  missingRanges,
6362
6348
  currentWorkers,
@@ -6535,7 +6521,7 @@ function explainStreamingEncodeGate(cfg, outputFormat, workerCount, durationSeco
6535
6521
  var DEFAULT_SEGMENTED_MIN_SECONDS = Number.POSITIVE_INFINITY;
6536
6522
 
6537
6523
  // src/services/distributed/planFrameCost.ts
6538
- import { join as join17 } from "node:path";
6524
+ import { join as join16 } from "node:path";
6539
6525
  import {
6540
6526
  closeCaptureSession as closeCaptureSession9,
6541
6527
  createCaptureSession as createCaptureSession9,
@@ -6545,7 +6531,7 @@ import {
6545
6531
 
6546
6532
  // src/services/distributed/chunkFrames.ts
6547
6533
  import { existsSync as existsSync12, readdirSync as readdirSync4 } from "node:fs";
6548
- import { extname as extname2, join as join16 } from "node:path";
6534
+ import { extname as extname2, join as join15 } from "node:path";
6549
6535
  import {
6550
6536
  compositionRequiresWebGpu as compositionRequiresWebGpu3,
6551
6537
  createFrameLookupTable as createFrameLookupTable2,
@@ -6555,7 +6541,7 @@ import {
6555
6541
 
6556
6542
  // src/services/distributed/shared.ts
6557
6543
  import { execFile as execFileCallback } from "node:child_process";
6558
- import { dirname as dirname8, join as join15 } from "node:path";
6544
+ import { dirname as dirname8, join as join14 } from "node:path";
6559
6545
  import { existsSync as existsSync11, readFileSync as readFileSync7 } from "node:fs";
6560
6546
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6561
6547
  import { promisify as promisify2 } from "node:util";
@@ -6571,7 +6557,7 @@ function isPlanAudioArtifactPath(path) {
6571
6557
  }
6572
6558
  function resolvePlanAudioPath(planDir) {
6573
6559
  for (const name of [PLAN_AUDIO_RELATIVE_PATH, PLAN_AUDIO_LEGACY_RELATIVE_PATH]) {
6574
- const candidate = join15(planDir, name);
6560
+ const candidate = join14(planDir, name);
6575
6561
  if (existsSync11(candidate)) return candidate;
6576
6562
  }
6577
6563
  return null;
@@ -6827,7 +6813,7 @@ function readProducerVersion() {
6827
6813
  const startDir = dirname8(fileURLToPath3(import.meta.url));
6828
6814
  let current = startDir;
6829
6815
  for (let i = 0; i < 10; i++) {
6830
- const candidate = join15(current, "package.json");
6816
+ const candidate = join14(current, "package.json");
6831
6817
  if (existsSync11(candidate)) {
6832
6818
  try {
6833
6819
  const pkg = JSON.parse(readFileSync7(candidate, "utf-8"));
@@ -6880,7 +6866,7 @@ function frameNumberFromFileName(name) {
6880
6866
  function rebuildExtractedFramesFromPlanDir(planDir, videos, indexMode = "dense-v1") {
6881
6867
  const result = [];
6882
6868
  for (const v of videos) {
6883
- const outputDir = join16(planDir, "video-frames", v.videoId);
6869
+ const outputDir = join15(planDir, "video-frames", v.videoId);
6884
6870
  if (!existsSync12(outputDir)) {
6885
6871
  throw new Error(
6886
6872
  `[renderChunk] planDir missing extracted video frames for ${JSON.stringify(v.videoId)}: ${outputDir} not present. plan() should have written frames here; the planDir is malformed.`
@@ -6899,7 +6885,7 @@ function rebuildExtractedFramesFromPlanDir(planDir, videos, indexMode = "dense-v
6899
6885
  if (!frameName) continue;
6900
6886
  const frameNumber = indexMode === "sparse-v2" ? frameNumberFromFileName(frameName) : null;
6901
6887
  const frameIndex = frameNumber === null ? i : frameNumber - 1;
6902
- framePaths.set(frameIndex, join16(outputDir, frameName));
6888
+ framePaths.set(frameIndex, join15(outputDir, frameName));
6903
6889
  }
6904
6890
  result.push({
6905
6891
  videoId: v.videoId,
@@ -6941,7 +6927,7 @@ function buildChunkCaptureOptions(input) {
6941
6927
  skipReadinessVideoIds: input.skipReadinessVideoIds,
6942
6928
  videoMetadataHints: input.videoMetadataHints,
6943
6929
  ...videoCaptureBeyondViewport !== void 0 ? { captureBeyondViewport: videoCaptureBeyondViewport } : {},
6944
- pageMotionBlur: "distributed-render",
6930
+ pageMotionBlur: encoder.pageMotionBlur ?? "distributed-render",
6945
6931
  // A fixed warmup count keeps `beginFrameTimeTicks` host-independent.
6946
6932
  lockWarmupTicks: true,
6947
6933
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
@@ -6989,14 +6975,14 @@ async function extractDeferredVideoFramesForChunk(input) {
6989
6975
  const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
6990
6976
  return Promise.all(
6991
6977
  deferred.map(async (video) => {
6992
- const outputDir = join16(input.outputRoot, video.videoId);
6978
+ const outputDir = join15(input.outputRoot, video.videoId);
6993
6979
  const indexes = [...shown.get(video.videoId) ?? []].sort((a, b) => a - b);
6994
6980
  if (indexes.length === 0) {
6995
6981
  return { ...video, outputDir, framePaths: /* @__PURE__ */ new Map(), ownedByLookup: false };
6996
6982
  }
6997
6983
  const range = video.deferredRange;
6998
6984
  const remoteUrl = remoteSources[range.sourcePath];
6999
- const sourcePath = remoteUrl ?? join16(input.planDir, ...range.sourcePath.split("/"));
6985
+ const sourcePath = remoteUrl ?? join15(input.planDir, ...range.sourcePath.split("/"));
7000
6986
  if (remoteUrl === void 0 && !existsSync12(sourcePath)) {
7001
6987
  throw new RenderChunkValidationError(
7002
6988
  MISSING_PLAN_ARTIFACT,
@@ -7045,12 +7031,12 @@ async function measurePlanSecondsPerFrame(input) {
7045
7031
  planVideos,
7046
7032
  frames: sampled,
7047
7033
  fps: fpsRatio,
7048
- outputRoot: join17(workDir, "frame-cost-video-frames"),
7034
+ outputRoot: join16(workDir, "frame-cost-video-frames"),
7049
7035
  cfg: input.cfg
7050
7036
  })
7051
7037
  ];
7052
7038
  const lookup = extracted.length > 0 ? createFrameLookupTable3(planVideos.videos, extracted, resolveRenderFpsConfig(fpsRatio).value) : null;
7053
- const compiledDir = join17(planDir, "compiled");
7039
+ const compiledDir = join16(planDir, "compiled");
7054
7040
  const fileServer = await createFileServer({
7055
7041
  projectDir: compiledDir,
7056
7042
  compiledDir,
@@ -7062,7 +7048,7 @@ async function measurePlanSecondsPerFrame(input) {
7062
7048
  try {
7063
7049
  session = await createCaptureSession9(
7064
7050
  fileServer.url,
7065
- join17(workDir, "frame-cost"),
7051
+ join16(workDir, "frame-cost"),
7066
7052
  buildChunkCaptureOptions({
7067
7053
  dimensions: { ...input, fpsNum: fps, fpsDen: 1 },
7068
7054
  encoder: input,
@@ -7084,7 +7070,7 @@ async function measurePlanSecondsPerFrame(input) {
7084
7070
 
7085
7071
  // src/services/render/stages/freezePlan.ts
7086
7072
  import { existsSync as existsSync13, mkdirSync as mkdirSync7, readFileSync as readFileSync8, readdirSync as readdirSync5, writeFileSync as writeFileSync4 } from "node:fs";
7087
- import { join as join18, relative as relative3, resolve as resolve7 } from "node:path";
7073
+ import { join as join17, relative as relative3, resolve as resolve7 } from "node:path";
7088
7074
 
7089
7075
  // src/services/distributed/planProtocol.ts
7090
7076
  var PLAN_SCHEMA_VERSION = 1;
@@ -7302,7 +7288,7 @@ function listPlanFiles(planDir) {
7302
7288
  function walk(dir) {
7303
7289
  const entries = readdirSync5(dir, { withFileTypes: true });
7304
7290
  for (const entry of entries) {
7305
- const full = join18(dir, entry.name);
7291
+ const full = join17(dir, entry.name);
7306
7292
  if (entry.isDirectory()) {
7307
7293
  walk(full);
7308
7294
  } else if (entry.isFile()) {
@@ -7343,15 +7329,15 @@ var PLAN_REMOTE_FILES_RELATIVE_PATH = "meta/remote-files.json";
7343
7329
  function writePlanRemoteFiles(planDir, files) {
7344
7330
  if (files.length === 0) return;
7345
7331
  const sorted = [...files].sort((a, b) => a.path < b.path ? -1 : 1);
7346
- writeFileSync4(join18(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH), JSON.stringify(sorted), "utf-8");
7332
+ writeFileSync4(join17(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH), JSON.stringify(sorted), "utf-8");
7347
7333
  }
7348
7334
  function readPlanRemoteFiles(planDir) {
7349
- const path = join18(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH);
7335
+ const path = join17(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH);
7350
7336
  return existsSync13(path) ? JSON.parse(readFileSync8(path, "utf-8")) : [];
7351
7337
  }
7352
7338
  function recomputePlanHashFromPlanDir(planDir, digests) {
7353
- const planJsonPath = join18(planDir, "plan.json");
7354
- const encoderJsonPath = join18(planDir, "meta", "encoder.json");
7339
+ const planJsonPath = join17(planDir, "plan.json");
7340
+ const encoderJsonPath = join17(planDir, "meta", "encoder.json");
7355
7341
  if (!existsSync13(planJsonPath)) {
7356
7342
  throw new Error(`[freezePlan] plan.json missing: ${planJsonPath}`);
7357
7343
  }
@@ -7388,18 +7374,18 @@ async function freezePlan(input) {
7388
7374
  if (!existsSync13(planDir)) {
7389
7375
  throw new Error(`[freezePlan] planDir does not exist: ${planDir}`);
7390
7376
  }
7391
- const metaDir = join18(planDir, "meta");
7377
+ const metaDir = join17(planDir, "meta");
7392
7378
  if (!existsSync13(metaDir)) mkdirSync7(metaDir, { recursive: true });
7393
7379
  writeFileSync4(
7394
- join18(metaDir, "composition.json"),
7380
+ join17(metaDir, "composition.json"),
7395
7381
  `${JSON.stringify(composition, null, 2)}
7396
7382
  `,
7397
7383
  "utf-8"
7398
7384
  );
7399
7385
  const encoderForCanonical = stripUndefined(encoder);
7400
7386
  const encoderConfigCanonicalJson = canonicalJsonStringify(encoderForCanonical);
7401
- writeFileSync4(join18(metaDir, "encoder.json"), encoderConfigCanonicalJson, "utf-8");
7402
- writeFileSync4(join18(metaDir, "chunks.json"), `${JSON.stringify(chunks, null, 2)}
7387
+ writeFileSync4(join17(metaDir, "encoder.json"), encoderConfigCanonicalJson, "utf-8");
7388
+ writeFileSync4(join17(metaDir, "chunks.json"), `${JSON.stringify(chunks, null, 2)}
7403
7389
  `, "utf-8");
7404
7390
  const { compositionHtml, assets } = collectPlanAssetShas(planDir, digests);
7405
7391
  const planHash = computePlanHash({
@@ -7423,7 +7409,7 @@ async function freezePlan(input) {
7423
7409
  duration: durationSeconds,
7424
7410
  hasAudio
7425
7411
  };
7426
- const planJsonPath = join18(planDir, "plan.json");
7412
+ const planJsonPath = join17(planDir, "plan.json");
7427
7413
  writeFileSync4(planJsonPath, `${JSON.stringify(planJson, null, 2)}
7428
7414
  `, "utf-8");
7429
7415
  return { planJsonPath, planHash };
@@ -7467,7 +7453,7 @@ function applyRuntimeEnvSnapshot(snapshot, env = process.env) {
7467
7453
  // src/services/distributed/chunkAudio.ts
7468
7454
  import { closeSync as closeSync4, existsSync as existsSync14, ftruncateSync, mkdirSync as mkdirSync9, openSync as openSync4, readFileSync as readFileSync9 } from "node:fs";
7469
7455
  import { writeFileSync as writeFileSync5, writeSync } from "node:fs";
7470
- import { join as join20 } from "node:path";
7456
+ import { join as join19 } from "node:path";
7471
7457
  import { appendRenderProvenanceArgs, describeFfmpegFailure as describeFfmpegFailure3, runFfmpeg as runFfmpeg6 } from "@hyperframes/engine";
7472
7458
 
7473
7459
  // src/services/distributed/mp4Stitch.ts
@@ -7477,10 +7463,10 @@ import {
7477
7463
  mkdirSync as mkdirSync8,
7478
7464
  mkdtempSync as mkdtempSync3,
7479
7465
  renameSync as renameSync3,
7480
- rmSync as rmSync7
7466
+ rmSync as rmSync8
7481
7467
  } from "node:fs";
7482
7468
  import { open } from "node:fs/promises";
7483
- import { dirname as dirname9, join as join19 } from "node:path";
7469
+ import { dirname as dirname9, join as join18 } from "node:path";
7484
7470
  import { finished, pipeline } from "node:stream/promises";
7485
7471
  function boxAt(src, off, at, limit) {
7486
7472
  let size = src.readUInt32BE(off);
@@ -7846,16 +7832,16 @@ async function writeStitchedMp4(chunkPaths, outputPath, audioPath) {
7846
7832
  const header = audioPath ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
7847
7833
  mkdirSync8(dirname9(outputPath), { recursive: true });
7848
7834
  const workDir = mkdtempSync3(`${outputPath}.stitch-`);
7849
- const out = createWriteStream(join19(workDir, "out.mp4"));
7835
+ const out = createWriteStream(join18(workDir, "out.mp4"));
7850
7836
  try {
7851
7837
  out.write(header);
7852
7838
  for (const [i, path] of sourcePaths.entries()) await copyPayload(path, layouts[i], i, out);
7853
7839
  out.end();
7854
7840
  await finished(out);
7855
- renameSync3(join19(workDir, "out.mp4"), outputPath);
7841
+ renameSync3(join18(workDir, "out.mp4"), outputPath);
7856
7842
  } finally {
7857
7843
  out.destroy();
7858
- rmSync7(workDir, { recursive: true, force: true });
7844
+ rmSync8(workDir, { recursive: true, force: true });
7859
7845
  }
7860
7846
  }
7861
7847
 
@@ -7946,7 +7932,7 @@ async function writeChunkAudioSlices(input) {
7946
7932
  });
7947
7933
  const bytes = readFileSync9(input.audioPath);
7948
7934
  const layout = { sizeBytes: bytes.length, payloadStart, payloadEnd, partStarts: {} };
7949
- mkdirSync9(join20(input.planDir, SLICE_DIR), { recursive: true });
7935
+ mkdirSync9(join19(input.planDir, SLICE_DIR), { recursive: true });
7950
7936
  input.chunks.forEach((_, index) => {
7951
7937
  const cut = audioCutForChunk(grid, input.chunks, index, input.fps);
7952
7938
  if (!cut) return;
@@ -7954,17 +7940,17 @@ async function writeChunkAudioSlices(input) {
7954
7940
  const start = offsets[cut.firstPacket];
7955
7941
  layout.partStarts[index] = start;
7956
7942
  const end = offsets[last] + track.sizes[last];
7957
- writeFileSync5(join20(input.planDir, chunkAudioSlicePath(index)), bytes.subarray(start, end));
7943
+ writeFileSync5(join19(input.planDir, chunkAudioSlicePath(index)), bytes.subarray(start, end));
7958
7944
  });
7959
7945
  const frame = [bytes.subarray(0, payloadStart), bytes.subarray(payloadEnd)];
7960
- writeFileSync5(join20(input.planDir, SLICE_FRAME), Buffer.concat(frame));
7961
- writeFileSync5(join20(input.planDir, SLICE_LAYOUT), JSON.stringify(layout));
7946
+ writeFileSync5(join19(input.planDir, SLICE_FRAME), Buffer.concat(frame));
7947
+ writeFileSync5(join19(input.planDir, SLICE_LAYOUT), JSON.stringify(layout));
7962
7948
  }
7963
7949
  function chunkAudioSource(planDir, chunkIndex, scratchPath) {
7964
- const layoutPath = join20(planDir, SLICE_LAYOUT);
7950
+ const layoutPath = join19(planDir, SLICE_LAYOUT);
7965
7951
  if (!existsSync14(layoutPath)) return resolvePlanAudioPath(planDir);
7966
7952
  const layout = JSON.parse(readFileSync9(layoutPath, "utf-8"));
7967
- const frame = readFileSync9(join20(planDir, SLICE_FRAME));
7953
+ const frame = readFileSync9(join19(planDir, SLICE_FRAME));
7968
7954
  const fd = openSync4(scratchPath, "w");
7969
7955
  try {
7970
7956
  writeSync(fd, frame, 0, layout.payloadStart, 0);
@@ -7977,7 +7963,7 @@ function chunkAudioSource(planDir, chunkIndex, scratchPath) {
7977
7963
  );
7978
7964
  const start = layout.partStarts[chunkIndex];
7979
7965
  if (start !== void 0) {
7980
- const part = readFileSync9(join20(planDir, chunkAudioSlicePath(chunkIndex)));
7966
+ const part = readFileSync9(join19(planDir, chunkAudioSlicePath(chunkIndex)));
7981
7967
  writeSync(fd, part, 0, part.length, start);
7982
7968
  }
7983
7969
  ftruncateSync(fd, layout.sizeBytes);
@@ -8033,7 +8019,7 @@ async function muxChunkWithAudio(input) {
8033
8019
  // src/services/distributed/planSize.ts
8034
8020
  import { createHash as createHash5 } from "node:crypto";
8035
8021
  import { lstatSync, readdirSync as readdirSync6 } from "node:fs";
8036
- import { extname as extname3, join as join21, relative as relative4 } from "node:path";
8022
+ import { extname as extname3, join as join20, relative as relative4 } from "node:path";
8037
8023
  var MAX_TOP_COMPONENTS = 10;
8038
8024
  var SOURCE_MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
8039
8025
  ".aac",
@@ -8145,7 +8131,7 @@ function walkRegularFiles(dir, visit) {
8145
8131
  return;
8146
8132
  }
8147
8133
  for (const entry of entries) {
8148
- const full = join21(dir, entry.name);
8134
+ const full = join20(dir, entry.name);
8149
8135
  if (entry.isDirectory()) {
8150
8136
  walkRegularFiles(full, visit);
8151
8137
  } else if (entry.isFile()) {
@@ -8221,7 +8207,6 @@ function chunkSizingUsesFrameCost(config) {
8221
8207
  }
8222
8208
  var CHUNK_EXCEEDS_WORKER_TIMEOUT = "CHUNK_EXCEEDS_WORKER_TIMEOUT";
8223
8209
  var ChunkExceedsWorkerTimeoutError = class extends Error {
8224
- // fallow-ignore-next-line unused-class-member
8225
8210
  code = CHUNK_EXCEEDS_WORKER_TIMEOUT;
8226
8211
  constructor(predictedChunkSeconds, workerTimeoutSeconds) {
8227
8212
  super(
@@ -8284,7 +8269,6 @@ var PlanTooLargeError = class extends Error {
8284
8269
  };
8285
8270
  var FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED";
8286
8271
  var FormatNotSupportedInDistributedError = class extends Error {
8287
- // fallow-ignore-next-line unused-class-member
8288
8272
  code = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
8289
8273
  format;
8290
8274
  reason;
@@ -8359,13 +8343,13 @@ function assertPositiveInteger(name, value) {
8359
8343
  }
8360
8344
  var FREEZE_OWNED_PLAN_FILES = [
8361
8345
  "plan.json",
8362
- join22("meta", "composition.json"),
8363
- join22("meta", "encoder.json"),
8364
- join22("meta", "chunks.json")
8346
+ join21("meta", "composition.json"),
8347
+ join21("meta", "encoder.json"),
8348
+ join21("meta", "chunks.json")
8365
8349
  ];
8366
8350
  function removeFreezeOwnedPlanFiles(planDir) {
8367
8351
  for (const relativePath of FREEZE_OWNED_PLAN_FILES) {
8368
- rmSync8(join22(planDir, relativePath), { force: true });
8352
+ rmSync9(join21(planDir, relativePath), { force: true });
8369
8353
  }
8370
8354
  }
8371
8355
  function buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize) {
@@ -8424,6 +8408,7 @@ function buildLockedRenderConfig(input) {
8424
8408
  chunkCount: input.chunkCount,
8425
8409
  runtimeEnv: input.runtimeEnv,
8426
8410
  variables: config.variables,
8411
+ pageMotionBlur: config.pageMotionBlur,
8427
8412
  ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
8428
8413
  ...input.separateAudio ? { separateAudio: true } : {}
8429
8414
  };
@@ -8497,19 +8482,19 @@ function createVideoSourceShipper(input) {
8497
8482
  const known = shipped.get(srcPath);
8498
8483
  if (known) return known;
8499
8484
  const stagedRel = pathInside(input.workVideoFramesDir, srcPath);
8500
- const current = stagedRel === null ? srcPath : join22(input.planDir, "video-frames", stagedRel);
8485
+ const current = stagedRel === null ? srcPath : join21(input.planDir, "video-frames", stagedRel);
8501
8486
  let result = null;
8502
8487
  for (const root of stagedRel === null ? [input.workCompiledDir, input.projectDir] : []) {
8503
8488
  const rel = pathInside(root, srcPath);
8504
- if (rel !== null && (existsSync15(join22(input.planDir, "compiled", rel)) || input.isRemote(srcPath))) {
8489
+ if (rel !== null && (existsSync15(join21(input.planDir, "compiled", rel)) || input.isRemote(srcPath))) {
8505
8490
  result = ["compiled", ...rel.split(sep2)].join("/");
8506
8491
  break;
8507
8492
  }
8508
8493
  }
8509
8494
  if (result === null) {
8510
8495
  result = `video-sources/${shipped.size}${extname4(current)}`;
8511
- const destination = join22(input.planDir, ...result.split("/"));
8512
- mkdirSync10(join22(input.planDir, "video-sources"), { recursive: true });
8496
+ const destination = join21(input.planDir, ...result.split("/"));
8497
+ mkdirSync10(join21(input.planDir, "video-sources"), { recursive: true });
8513
8498
  try {
8514
8499
  linkSync(current, destination);
8515
8500
  } catch {
@@ -8521,7 +8506,7 @@ function createVideoSourceShipper(input) {
8521
8506
  };
8522
8507
  }
8523
8508
  function plansSeparateAudio(config, options) {
8524
- return options.deferAudio === true && config.format === "mp4" && config.cfr !== true;
8509
+ return options.deferAudio === true && config.format !== "png-sequence" && config.cfr !== true;
8525
8510
  }
8526
8511
  async function compilePlanComposition(projectDir, config, planDir, options) {
8527
8512
  rejectUnsupportedDistributedFormat(config);
@@ -8558,14 +8543,14 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8558
8543
  variables: config.variables
8559
8544
  });
8560
8545
  const entryFile = config.entryFile ?? "index.html";
8561
- const htmlPath = join22(projectDir, entryFile);
8546
+ const htmlPath = join21(projectDir, entryFile);
8562
8547
  if (!existsSync15(htmlPath)) {
8563
8548
  throw new Error(`[plan] entry file not found: ${htmlPath}`);
8564
8549
  }
8565
- const workDir = join22(planDir, ".plan-work");
8566
- rmSync8(workDir, { recursive: true, force: true });
8550
+ const workDir = join21(planDir, ".plan-work");
8551
+ rmSync9(workDir, { recursive: true, force: true });
8567
8552
  mkdirSync10(workDir, { recursive: true });
8568
- const compiledDir = join22(workDir, "compiled");
8553
+ const compiledDir = join21(workDir, "compiled");
8569
8554
  mkdirSync10(compiledDir, { recursive: true });
8570
8555
  let stageStarted = Date.now();
8571
8556
  cpSync2(projectDir, compiledDir, {
@@ -8579,10 +8564,10 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8579
8564
  }
8580
8565
  });
8581
8566
  logPlanStage(log, "copy", stageStarted);
8582
- const finalCompiledDir = join22(planDir, "compiled");
8567
+ const finalCompiledDir = join21(planDir, "compiled");
8583
8568
  const needsAlpha = outputNeedsAlpha(config.format);
8584
8569
  const remoteFiles = Object.entries(options.remoteFiles ?? {});
8585
- const remoteLocalPath = (rel) => join22(projectDir, ...rel.split("/"));
8570
+ const remoteLocalPath = (rel) => join21(projectDir, ...rel.split("/"));
8586
8571
  const urlByAbsentLocalPath = new Map(
8587
8572
  remoteFiles.map(([rel, file]) => [remoteLocalPath(rel), file.url])
8588
8573
  );
@@ -8681,22 +8666,21 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8681
8666
  forceScreenshot,
8682
8667
  width,
8683
8668
  height,
8684
- probeResult,
8685
8669
  totalFrames
8686
8670
  };
8687
8671
  }
8688
- function mixPlanAudio(c, compiledDir, seconds, pcmPath) {
8672
+ function mixPlanAudio(c, compiledDir) {
8689
8673
  return runAudioStage({
8690
8674
  projectDir: c.projectDir,
8691
8675
  workDir: c.workDir,
8692
8676
  compiledDir,
8693
8677
  urlByAbsentLocalPath: c.urlByAbsentLocalPath,
8694
- duration: seconds,
8678
+ totalFrames: c.totalFrames,
8679
+ fps: c.job.config.fps,
8695
8680
  ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
8696
8681
  audioGain: c.cfg.audioGain,
8697
8682
  audios: c.composition.audios,
8698
8683
  masterFxChain: c.composition.masterFxChain,
8699
- ...pcmPath ? { keepEncoderInputAt: pcmPath } : {},
8700
8684
  abortSignal: c.abortSignal,
8701
8685
  assertNotAborted: c.assertNotAborted
8702
8686
  });
@@ -8720,16 +8704,6 @@ async function extractPlanVideos(c, deferRangeExtraction) {
8720
8704
  }
8721
8705
  return extractResult;
8722
8706
  }
8723
- async function holdPlanAudioPeak(c, audioPath, pcmPath, seconds) {
8724
- const peak = await constrainAacTruePeak({
8725
- audioPath,
8726
- ...existsSync15(pcmPath) ? { sourcePath: pcmPath } : {},
8727
- targetDurationSeconds: seconds,
8728
- signal: c.abortSignal,
8729
- timeoutMs: c.cfg.ffmpegProcessTimeout
8730
- }).finally(() => rmSync8(pcmPath, { force: true }));
8731
- if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
8732
- }
8733
8707
  async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8734
8708
  const planDir = executionPlanDir;
8735
8709
  const c = await compilePlanComposition(projectDir, config, planDir, options);
@@ -8751,7 +8725,6 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8751
8725
  forceScreenshot,
8752
8726
  width,
8753
8727
  height,
8754
- probeResult,
8755
8728
  totalFrames
8756
8729
  } = c;
8757
8730
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
@@ -8767,17 +8740,17 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8767
8740
  let stageStarted = Date.now();
8768
8741
  const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
8769
8742
  logPlanStage(log, "extract", stageStarted);
8770
- const stagedVideoFrames = join22(compiledDir, "__hyperframes_video_frames");
8771
- const videoFramesDst = join22(planDir, "video-frames");
8772
- if (existsSync15(videoFramesDst)) rmSync8(videoFramesDst, { recursive: true, force: true });
8773
- const videoSourcesDir = join22(planDir, "video-sources");
8774
- rmSync8(videoSourcesDir, { recursive: true, force: true });
8743
+ const stagedVideoFrames = join21(compiledDir, "__hyperframes_video_frames");
8744
+ const videoFramesDst = join21(planDir, "video-frames");
8745
+ if (existsSync15(videoFramesDst)) rmSync9(videoFramesDst, { recursive: true, force: true });
8746
+ const videoSourcesDir = join21(planDir, "video-sources");
8747
+ rmSync9(videoSourcesDir, { recursive: true, force: true });
8775
8748
  if (existsSync15(stagedVideoFrames)) {
8776
8749
  renameSync4(stagedVideoFrames, videoFramesDst);
8777
8750
  } else {
8778
8751
  mkdirSync10(videoFramesDst, { recursive: true });
8779
8752
  }
8780
- if (existsSync15(finalCompiledDir)) rmSync8(finalCompiledDir, { recursive: true, force: true });
8753
+ if (existsSync15(finalCompiledDir)) rmSync9(finalCompiledDir, { recursive: true, force: true });
8781
8754
  renameSync4(compiledDir, finalCompiledDir);
8782
8755
  const shipVideoSource = createVideoSourceShipper({
8783
8756
  planDir,
@@ -8807,7 +8780,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8807
8780
  };
8808
8781
  })
8809
8782
  });
8810
- mkdirSync10(join22(planDir, "meta"), { recursive: true });
8783
+ mkdirSync10(join21(planDir, "meta"), { recursive: true });
8811
8784
  writePlanRemoteFiles(
8812
8785
  planDir,
8813
8786
  remoteFiles.map(([rel, { sha256, sizeBytes }]) => ({
@@ -8817,23 +8790,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8817
8790
  }))
8818
8791
  );
8819
8792
  writeFileSync6(
8820
- join22(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
8793
+ join21(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
8821
8794
  JSON.stringify(planVideosJson, null, 2),
8822
8795
  "utf-8"
8823
8796
  );
8824
8797
  const digests = options.digests ?? /* @__PURE__ */ new Map();
8825
8798
  const separateAudio = plansSeparateAudio(config, options);
8826
8799
  const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options);
8827
- const audioSeconds = chunksCarryAudio ? totalFrames / config.fps : probeResult.duration;
8828
- const planMixPcmPath = join22(workDir, "plan-mix.wav");
8829
8800
  stageStarted = Date.now();
8830
8801
  const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
8831
- separateAudio ? null : mixPlanAudio(
8832
- c,
8833
- finalCompiledDir,
8834
- audioSeconds,
8835
- chunksCarryAudio ? planMixPcmPath : void 0
8836
- ).finally(() => logPlanStage(log, "audio", stageStarted)),
8802
+ separateAudio ? null : mixPlanAudio(c, finalCompiledDir).finally(() => logPlanStage(log, "audio", stageStarted)),
8837
8803
  (async () => {
8838
8804
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
8839
8805
  await prehashPlanFiles(dir, digests);
@@ -8886,14 +8852,11 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8886
8852
  if (audioResult?.audioError) {
8887
8853
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
8888
8854
  }
8889
- const planAudioPath = join22(planDir, PLAN_AUDIO_RELATIVE_PATH);
8855
+ const planAudioPath = join21(planDir, PLAN_AUDIO_RELATIVE_PATH);
8890
8856
  if (audioResult?.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8891
8857
  renameSync4(audioResult.audioOutputPath, planAudioPath);
8892
8858
  }
8893
8859
  if (chunksCarryAudio && existsSync15(planAudioPath)) {
8894
- stageStarted = Date.now();
8895
- await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
8896
- logPlanStage(log, "audio-peak", stageStarted);
8897
8860
  if (options.sliceChunkAudio) {
8898
8861
  stageStarted = Date.now();
8899
8862
  await writeChunkAudioSlices({
@@ -8938,7 +8901,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8938
8901
  format: config.format
8939
8902
  };
8940
8903
  try {
8941
- rmSync8(workDir, { recursive: true, force: true });
8904
+ rmSync9(workDir, { recursive: true, force: true });
8942
8905
  } catch (err) {
8943
8906
  log.warn("[plan] failed to remove temp work dir", {
8944
8907
  workDir,
@@ -8995,25 +8958,20 @@ async function buildPlanAudio(projectDir, config, workRoot, outputPath, options
8995
8958
  executionPlanSizeLimitBytes: Number.MAX_SAFE_INTEGER
8996
8959
  });
8997
8960
  try {
8998
- const seconds = c.totalFrames / config.fps;
8999
- const pcmPath = join22(c.workDir, "plan-mix.wav");
9000
8961
  let stageStarted = Date.now();
9001
8962
  await extractPlanVideos(c, true);
9002
8963
  logPlanStage(c.log, "extract", stageStarted);
9003
8964
  stageStarted = Date.now();
9004
- const mixed = await mixPlanAudio(c, c.compiledDir, seconds, pcmPath);
8965
+ const mixed = await mixPlanAudio(c, c.compiledDir);
9005
8966
  logPlanStage(c.log, "audio", stageStarted);
9006
8967
  if (mixed.audioError) {
9007
8968
  applyDistributedAudioWarningPolicy(c.job, mixed.audioError, mixed.audioFailures, c.log);
9008
8969
  }
9009
8970
  if (!mixed.hasAudio || !existsSync15(mixed.audioOutputPath)) return { hasAudio: false };
9010
8971
  renameSync4(mixed.audioOutputPath, outputPath);
9011
- stageStarted = Date.now();
9012
- await holdPlanAudioPeak(c, outputPath, pcmPath, seconds);
9013
- logPlanStage(c.log, "audio-peak", stageStarted);
9014
8972
  return { hasAudio: true };
9015
8973
  } finally {
9016
- rmSync8(c.workDir, { recursive: true, force: true });
8974
+ rmSync9(c.workDir, { recursive: true, force: true });
9017
8975
  }
9018
8976
  }
9019
8977
  async function plan(projectDir, config, planDir, options = {}) {
@@ -9043,12 +9001,12 @@ import {
9043
9001
  readFileSync as readFileSync10,
9044
9002
  readdirSync as readdirSync7,
9045
9003
  renameSync as renameSync6,
9046
- rmSync as rmSync10,
9004
+ rmSync as rmSync11,
9047
9005
  statSync as statSync6,
9048
9006
  writeFileSync as writeFileSync8
9049
9007
  } from "node:fs";
9050
9008
  import { tmpdir } from "node:os";
9051
- import { dirname as dirname11, isAbsolute as isAbsolute4, join as join25, relative as relative6, resolve as resolve8, sep as sep3 } from "node:path";
9009
+ import { dirname as dirname11, isAbsolute as isAbsolute4, join as join24, relative as relative6, resolve as resolve8, sep as sep3 } from "node:path";
9052
9010
  import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
9053
9011
  import { createFrameLookupTable as createFrameLookupTable4 } from "@hyperframes/engine";
9054
9012
 
@@ -9060,11 +9018,11 @@ import {
9060
9018
  mkdirSync as mkdirSync11,
9061
9019
  mkdtempSync as mkdtempSync4,
9062
9020
  renameSync as renameSync5,
9063
- rmSync as rmSync9,
9021
+ rmSync as rmSync10,
9064
9022
  statSync as statSync5,
9065
9023
  writeFileSync as writeFileSync7
9066
9024
  } from "node:fs";
9067
- import { dirname as dirname10, join as join24 } from "node:path";
9025
+ import { dirname as dirname10, join as join23 } from "node:path";
9068
9026
 
9069
9027
  // src/services/distributed/planV2Errors.ts
9070
9028
  var PLAN_V2_INTEGRITY_UNRECOVERABLE = "PLAN_V2_INTEGRITY_UNRECOVERABLE";
@@ -9079,7 +9037,7 @@ var PlanV2IntegrityError = class extends Error {
9079
9037
  };
9080
9038
 
9081
9039
  // src/services/distributed/planV2Layout.ts
9082
- import { join as join23 } from "node:path";
9040
+ import { join as join22 } from "node:path";
9083
9041
  var SHA256_PATTERN = /^[0-9a-f]{64}$/;
9084
9042
  function assertPlanV2Sha256(value, field) {
9085
9043
  if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
@@ -9089,7 +9047,7 @@ function assertPlanV2Sha256(value, field) {
9089
9047
  }
9090
9048
  function planV2BlobPath(root, sha256) {
9091
9049
  const digest = assertPlanV2Sha256(sha256, "artifact sha256");
9092
- return join23(root, "artifacts", "sha256", digest.slice(0, 2), digest);
9050
+ return join22(root, "artifacts", "sha256", digest.slice(0, 2), digest);
9093
9051
  }
9094
9052
 
9095
9053
  // src/services/distributed/planV2Publisher.ts
@@ -9130,7 +9088,7 @@ var LocalPlanV2ArtifactPublisher = class {
9130
9088
  this.destinationDir = destinationDir;
9131
9089
  this.#linkFile = options.linkFile ?? linkSync2;
9132
9090
  mkdirSync11(dirname10(destinationDir), { recursive: true });
9133
- this.temporaryDir = mkdtempSync4(join24(dirname10(destinationDir), ".plan-v2-publish-"));
9091
+ this.temporaryDir = mkdtempSync4(join23(dirname10(destinationDir), ".plan-v2-publish-"));
9134
9092
  }
9135
9093
  async putBlob(blob) {
9136
9094
  const digest = assertPlanV2Sha256(blob.sha256, "published blob sha256");
@@ -9143,8 +9101,8 @@ var LocalPlanV2ArtifactPublisher = class {
9143
9101
  const destinationPath = planV2BlobPath(this.temporaryDir, digest);
9144
9102
  if (existsSync16(destinationPath)) return;
9145
9103
  mkdirSync11(dirname10(destinationPath), { recursive: true });
9146
- const stagingDir = mkdtempSync4(join24(dirname10(destinationPath), ".plan-v2-blob-"));
9147
- const temporaryPath = join24(stagingDir, "blob");
9104
+ const stagingDir = mkdtempSync4(join23(dirname10(destinationPath), ".plan-v2-blob-"));
9105
+ const temporaryPath = join23(stagingDir, "blob");
9148
9106
  try {
9149
9107
  try {
9150
9108
  this.#linkFile(blob.sourcePath, temporaryPath);
@@ -9154,7 +9112,7 @@ var LocalPlanV2ArtifactPublisher = class {
9154
9112
  }
9155
9113
  renameSync5(temporaryPath, destinationPath);
9156
9114
  } finally {
9157
- rmSync9(stagingDir, { recursive: true, force: true });
9115
+ rmSync10(stagingDir, { recursive: true, force: true });
9158
9116
  }
9159
9117
  }
9160
9118
  async commitManifest(manifestBytes) {
@@ -9165,13 +9123,13 @@ var LocalPlanV2ArtifactPublisher = class {
9165
9123
  );
9166
9124
  }
9167
9125
  }
9168
- writeFileSync7(join24(this.temporaryDir, "plan.json"), manifestBytes, "utf-8");
9126
+ writeFileSync7(join23(this.temporaryDir, "plan.json"), manifestBytes, "utf-8");
9169
9127
  renameSync5(this.temporaryDir, this.destinationDir);
9170
9128
  this.#committed = true;
9171
9129
  }
9172
9130
  async abort() {
9173
9131
  if (!this.#committed) {
9174
- rmSync9(this.temporaryDir, { recursive: true, force: true });
9132
+ rmSync10(this.temporaryDir, { recursive: true, force: true });
9175
9133
  }
9176
9134
  }
9177
9135
  };
@@ -9210,7 +9168,7 @@ function listFiles(root) {
9210
9168
  const rootResolved = resolve8(root);
9211
9169
  function walk(dir) {
9212
9170
  for (const entry of readdirSync7(dir, { withFileTypes: true })) {
9213
- const absolutePath = join25(dir, entry.name);
9171
+ const absolutePath = join24(dir, entry.name);
9214
9172
  if (entry.isDirectory()) {
9215
9173
  walk(absolutePath);
9216
9174
  } else if (entry.isFile()) {
@@ -9237,13 +9195,13 @@ function assertValidExtractionCacheCompleteSentinel(path) {
9237
9195
  }
9238
9196
  }
9239
9197
  function validateExtractionCacheCompleteSentinels(executionPlanDir) {
9240
- const videoRoot = join25(executionPlanDir, "video-frames");
9198
+ const videoRoot = join24(executionPlanDir, "video-frames");
9241
9199
  if (!existsSync17(videoRoot)) return;
9242
9200
  for (const videoEntry of readdirSync7(videoRoot, { withFileTypes: true })) {
9243
9201
  if (!videoEntry.isDirectory()) continue;
9244
- const videoDir = join25(videoRoot, videoEntry.name);
9202
+ const videoDir = join24(videoRoot, videoEntry.name);
9245
9203
  if (readdirSync7(videoDir).includes(EXTRACTION_CACHE_COMPLETE_SENTINEL)) {
9246
- const sentinelPath = join25(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL);
9204
+ const sentinelPath = join24(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL);
9247
9205
  assertValidExtractionCacheCompleteSentinel(sentinelPath);
9248
9206
  }
9249
9207
  }
@@ -9290,7 +9248,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
9290
9248
  if (video.deferredRange) {
9291
9249
  const framePaths2 = /* @__PURE__ */ new Map();
9292
9250
  for (let index = 0; index < video.totalFrames; index++) {
9293
- framePaths2.set(index, join25(outputDir, String(index)));
9251
+ framePaths2.set(index, join24(outputDir, String(index)));
9294
9252
  }
9295
9253
  return { ...video, outputDir, framePaths: framePaths2, ownedByLookup: false };
9296
9254
  }
@@ -9298,7 +9256,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
9298
9256
  const framePaths = /* @__PURE__ */ new Map();
9299
9257
  for (const frameName of frameNames) {
9300
9258
  if (frameName === EXTRACTION_CACHE_COMPLETE_SENTINEL) {
9301
- assertValidExtractionCacheCompleteSentinel(join25(outputDir, frameName));
9259
+ assertValidExtractionCacheCompleteSentinel(join24(outputDir, frameName));
9302
9260
  continue;
9303
9261
  }
9304
9262
  const match = /(\d+)(?=\.[^.]+$)/.exec(frameName);
@@ -9317,7 +9275,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
9317
9275
  `duplicate extracted frame index ${frameIndex} in ${outputDir}`
9318
9276
  );
9319
9277
  }
9320
- framePaths.set(frameIndex, join25(outputDir, frameName));
9278
+ framePaths.set(frameIndex, join24(outputDir, frameName));
9321
9279
  }
9322
9280
  return {
9323
9281
  ...video,
@@ -9338,7 +9296,7 @@ function parsePlanVideosJson2(value) {
9338
9296
  }
9339
9297
  }
9340
9298
  function materializeExtractedVideoDirectories(planDir) {
9341
- const videosPath = join25(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9299
+ const videosPath = join24(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9342
9300
  if (!existsSync17(videosPath)) return;
9343
9301
  const videos = parsePlanVideosJson2(readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH));
9344
9302
  for (const video of videos.extracted) {
@@ -9367,13 +9325,13 @@ function parseChunkSlices(value) {
9367
9325
  });
9368
9326
  }
9369
9327
  function buildVideoChunkDependencies(executionPlanDir, dimensions) {
9370
- const videoRoot = join25(executionPlanDir, "video-frames");
9328
+ const videoRoot = join24(executionPlanDir, "video-frames");
9371
9329
  const hasExtractedFrames = existsSync17(videoRoot) && listFiles(videoRoot).length > 0;
9372
- const videosPath = join25(executionPlanDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9330
+ const videosPath = join24(executionPlanDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9373
9331
  if (!existsSync17(videosPath)) {
9374
9332
  return hasExtractedFrames ? { mode: "full-source-pack", dependencies: null } : { mode: "exact-rendered-frames", dependencies: /* @__PURE__ */ new Map() };
9375
9333
  }
9376
- const chunksPath = join25(executionPlanDir, "meta", "chunks.json");
9334
+ const chunksPath = join24(executionPlanDir, "meta", "chunks.json");
9377
9335
  const videos = readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH);
9378
9336
  const chunks = readJsonFile(chunksPath, "meta/chunks.json");
9379
9337
  const parsedVideos = parsePlanVideosJson2(videos);
@@ -9429,13 +9387,13 @@ function computeManifestHash(manifest) {
9429
9387
  function writeBlob(sourcePath, destinationPath) {
9430
9388
  if (existsSync17(destinationPath)) return;
9431
9389
  mkdirSync12(dirname11(destinationPath), { recursive: true });
9432
- const temporaryDir = mkdtempSync5(join25(dirname11(destinationPath), ".plan-v2-blob-"));
9433
- const temporaryPath = join25(temporaryDir, "blob");
9390
+ const temporaryDir = mkdtempSync5(join24(dirname11(destinationPath), ".plan-v2-blob-"));
9391
+ const temporaryPath = join24(temporaryDir, "blob");
9434
9392
  try {
9435
9393
  copyFileSync7(sourcePath, temporaryPath);
9436
9394
  renameSync6(temporaryPath, destinationPath);
9437
9395
  } finally {
9438
- rmSync10(temporaryDir, { recursive: true, force: true });
9396
+ rmSync11(temporaryDir, { recursive: true, force: true });
9439
9397
  }
9440
9398
  }
9441
9399
  function publishBlobFor(absolutePath, digests) {
@@ -9466,7 +9424,7 @@ async function putTasks(tasks) {
9466
9424
  if (failure) throw failure.reason;
9467
9425
  }
9468
9426
  function buildPlanV2Publication(executionPlanDir, digests) {
9469
- const executionPlanPath = join25(executionPlanDir, "plan.json");
9427
+ const executionPlanPath = join24(executionPlanDir, "plan.json");
9470
9428
  if (!existsSync17(executionPlanPath)) {
9471
9429
  throw new PlanV2IntegrityError(`execution plan is missing plan.json: ${executionPlanPath}`);
9472
9430
  }
@@ -9494,7 +9452,7 @@ function buildPlanV2Publication(executionPlanDir, digests) {
9494
9452
  }
9495
9453
  validateExtractionCacheCompleteSentinels(executionPlanDir);
9496
9454
  const videoDependencyPlan = buildVideoChunkDependencies(executionPlanDir, dimensions);
9497
- const encoder = readJsonFile(join25(executionPlanDir, "meta", "encoder.json"), "meta/encoder.json");
9455
+ const encoder = readJsonFile(join24(executionPlanDir, "meta", "encoder.json"), "meta/encoder.json");
9498
9456
  const files = listFiles(executionPlanDir);
9499
9457
  const chunkAudio = !isRecord3(encoder) || encoder.chunksCarryAudio !== true ? "none" : files.some((file) => chunkAudioSliceTarget(file.path) !== null) ? "sliced" : "whole";
9500
9458
  for (const file of files) {
@@ -9555,20 +9513,20 @@ function createPlanV2FromExecutionPlan(executionPlanDir, planV2Dir) {
9555
9513
  throw new PlanV2IntegrityError("a local plan v2 directory cannot reference remote files");
9556
9514
  }
9557
9515
  mkdirSync12(dirname11(planV2Dir), { recursive: true });
9558
- const tempDir = mkdtempSync5(join25(dirname11(planV2Dir), ".plan-v2-build-"));
9516
+ const tempDir = mkdtempSync5(join24(dirname11(planV2Dir), ".plan-v2-build-"));
9559
9517
  try {
9560
9518
  for (const blob of publication.blobs) {
9561
9519
  writeBlob(blob.sourcePath, planV2BlobPath(tempDir, blob.sha256));
9562
9520
  }
9563
9521
  writeFileSync8(
9564
- join25(tempDir, "plan.json"),
9522
+ join24(tempDir, "plan.json"),
9565
9523
  canonicalJsonStringify(publication.manifest),
9566
9524
  "utf-8"
9567
9525
  );
9568
9526
  renameSync6(tempDir, planV2Dir);
9569
9527
  return resultFromManifest(planV2Dir, publication.manifest);
9570
9528
  } catch (error) {
9571
- rmSync10(tempDir, { recursive: true, force: true });
9529
+ rmSync11(tempDir, { recursive: true, force: true });
9572
9530
  throw error;
9573
9531
  }
9574
9532
  }
@@ -9608,7 +9566,7 @@ async function publishPlanV2FromV1(executionPlanDir, publisher) {
9608
9566
  return publishPlanV2FromExecutionPlan(executionPlanDir, publisher);
9609
9567
  }
9610
9568
  async function planV2WithPublisher(projectDir, config, publisher, options = {}) {
9611
- const stagingRoot = mkdtempSync5(join25(options.stagingParentDir ?? tmpdir(), ".execution-plan-"));
9569
+ const stagingRoot = mkdtempSync5(join24(options.stagingParentDir ?? tmpdir(), ".execution-plan-"));
9612
9570
  const digests = /* @__PURE__ */ new Map();
9613
9571
  try {
9614
9572
  await buildLocalExecutionPlan(projectDir, config, stagingRoot, {
@@ -9619,7 +9577,7 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9619
9577
  // artifactTargets ships every compiled/ file to all chunks, so each is in the manifest.
9620
9578
  whileAudioMixes: () => putBlobs(
9621
9579
  publisher,
9622
- listFiles(join25(stagingRoot, "compiled")).map(
9580
+ listFiles(join24(stagingRoot, "compiled")).map(
9623
9581
  (file) => publishBlobFor(file.absolutePath, digests)
9624
9582
  )
9625
9583
  ),
@@ -9635,7 +9593,7 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9635
9593
  }
9636
9594
  throw error;
9637
9595
  } finally {
9638
- rmSync10(stagingRoot, { recursive: true, force: true });
9596
+ rmSync11(stagingRoot, { recursive: true, force: true });
9639
9597
  }
9640
9598
  }
9641
9599
  async function planV2(projectDir, config, planV2Dir) {
@@ -9651,7 +9609,7 @@ async function planV2(projectDir, config, planV2Dir) {
9651
9609
  function resultFromManifest(planV2Dir, manifest) {
9652
9610
  return {
9653
9611
  planDir: planV2Dir,
9654
- manifestPath: join25(planV2Dir, "plan.json"),
9612
+ manifestPath: join24(planV2Dir, "plan.json"),
9655
9613
  planProtocol: PLAN_PROTOCOL_V2,
9656
9614
  planHash: manifest.planHash,
9657
9615
  sourcePlanV1Hash: getPlanV2ExecutionPlanHash(manifest),
@@ -9787,7 +9745,7 @@ function parsePlanV2Manifest(value) {
9787
9745
  return parsed;
9788
9746
  }
9789
9747
  function readPlanV2Manifest(planV2Dir) {
9790
- const manifestPath = join25(planV2Dir, "plan.json");
9748
+ const manifestPath = join24(planV2Dir, "plan.json");
9791
9749
  if (!existsSync17(manifestPath)) {
9792
9750
  throw new PlanV2IntegrityError(`missing v2 manifest: ${manifestPath}`);
9793
9751
  }
@@ -9833,16 +9791,16 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
9833
9791
  sourcePath: verifyBlob(planV2Dir, artifact)
9834
9792
  }));
9835
9793
  mkdirSync12(dirname11(destinationDir), { recursive: true });
9836
- const tempDir = mkdtempSync5(join25(dirname11(destinationDir), ".plan-v2-materialize-"));
9794
+ const tempDir = mkdtempSync5(join24(dirname11(destinationDir), ".plan-v2-materialize-"));
9837
9795
  try {
9838
9796
  for (const { artifact, sourcePath } of verified) {
9839
- const destinationPath = join25(tempDir, ...artifact.path.split("/"));
9797
+ const destinationPath = join24(tempDir, ...artifact.path.split("/"));
9840
9798
  mkdirSync12(dirname11(destinationPath), { recursive: true });
9841
9799
  copyFileSync7(sourcePath, destinationPath);
9842
9800
  }
9843
9801
  if (target.role === "chunk") materializeExtractedVideoDirectories(tempDir);
9844
9802
  writeFileSync8(
9845
- join25(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
9803
+ join24(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
9846
9804
  canonicalJsonStringify({
9847
9805
  manifest,
9848
9806
  target,
@@ -9852,7 +9810,7 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
9852
9810
  );
9853
9811
  renameSync6(tempDir, destinationDir);
9854
9812
  } catch (error) {
9855
- rmSync10(tempDir, { recursive: true, force: true });
9813
+ rmSync11(tempDir, { recursive: true, force: true });
9856
9814
  throw error;
9857
9815
  }
9858
9816
  return {
@@ -9865,7 +9823,7 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
9865
9823
  // Join the artifact's OWN name, not the current constant: a plan written
9866
9824
  // before the container change carries the legacy name, and materializing it
9867
9825
  // under the new one would point at a file that was never written.
9868
- audioPath: target.role === "assembler" ? audioArtifact ? join25(destinationDir, audioArtifact.path) : null : null
9826
+ audioPath: target.role === "assembler" ? audioArtifact ? join24(destinationDir, audioArtifact.path) : null : null
9869
9827
  };
9870
9828
  }
9871
9829
  function assertRemoteSources(remoteSources, artifacts) {
@@ -9884,7 +9842,7 @@ function validatePlanV2MaterializedTarget(planDir, expectedTarget) {
9884
9842
  return readValidatedPlanV2Materialization(planDir, expectedTarget)?.manifest ?? null;
9885
9843
  }
9886
9844
  function readValidatedPlanV2Materialization(planDir, expectedTarget) {
9887
- const markerPath = join25(planDir, PLAN_V2_MATERIALIZATION_MARKER);
9845
+ const markerPath = join24(planDir, PLAN_V2_MATERIALIZATION_MARKER);
9888
9846
  if (!existsSync17(markerPath)) return null;
9889
9847
  const marker = readJsonFile(markerPath, "materialization marker");
9890
9848
  if (!isRecord3(marker) || !isRecord3(marker.manifest) || !isRecord3(marker.target)) {
@@ -9902,7 +9860,7 @@ function readValidatedPlanV2Materialization(planDir, expectedTarget) {
9902
9860
  assertRemoteSources(remoteSources, required);
9903
9861
  for (const artifact of required) {
9904
9862
  if (Object.hasOwn(remoteSources, artifact.path)) continue;
9905
- const path = join25(planDir, ...artifact.path.split("/"));
9863
+ const path = join24(planDir, ...artifact.path.split("/"));
9906
9864
  if (!existsSync17(path) || statSync6(path).size !== artifact.sizeBytes) {
9907
9865
  throw new PlanV2IntegrityError(
9908
9866
  `materialized artifact missing or truncated: ${artifact.path}`
@@ -9916,9 +9874,9 @@ function readValidatedPlanV2Materialization(planDir, expectedTarget) {
9916
9874
  }
9917
9875
 
9918
9876
  // src/services/distributed/planV2Execution.ts
9919
- import { mkdtempSync as mkdtempSync7, rmSync as rmSync13 } from "node:fs";
9877
+ import { mkdtempSync as mkdtempSync7, rmSync as rmSync14 } from "node:fs";
9920
9878
  import { tmpdir as tmpdir2 } from "node:os";
9921
- import { join as join28 } from "node:path";
9879
+ import { join as join27 } from "node:path";
9922
9880
 
9923
9881
  // src/services/distributed/assemble.ts
9924
9882
  import {
@@ -9929,11 +9887,11 @@ import {
9929
9887
  readFileSync as readFileSync11,
9930
9888
  readdirSync as readdirSync8,
9931
9889
  renameSync as renameSync7,
9932
- rmSync as rmSync11,
9890
+ rmSync as rmSync12,
9933
9891
  statSync as statSync7,
9934
9892
  writeFileSync as writeFileSync9
9935
9893
  } from "node:fs";
9936
- import { dirname as dirname12, join as join26 } from "node:path";
9894
+ import { dirname as dirname12, join as join25 } from "node:path";
9937
9895
  import {
9938
9896
  appendRenderProvenanceArgs as appendRenderProvenanceArgs2,
9939
9897
  describeFfmpegFailure as describeFfmpegFailure4,
@@ -9941,7 +9899,8 @@ import {
9941
9899
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME4,
9942
9900
  muxVideoWithAudio as muxVideoWithAudio2,
9943
9901
  resolveConfig as resolveConfig4,
9944
- runFfmpeg as runFfmpeg7
9902
+ runFfmpeg as runFfmpeg7,
9903
+ appendSoftwareEncoderThreadsArg
9945
9904
  } from "@hyperframes/engine";
9946
9905
  import { fpsToFfmpegArg as fpsToFfmpegArg3 } from "@hyperframes/core";
9947
9906
  function cfrRateControlArgs(locked) {
@@ -9995,7 +9954,7 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
9995
9954
  };
9996
9955
  try {
9997
9956
  const videoSources = cfrEncoder ? [await reencodeCfr(ctx, chunkPaths, cfrEncoder)] : chunkPaths;
9998
- const finalPath = join26(workDir, `output.${format}`);
9957
+ const finalPath = join25(workDir, `output.${format}`);
9999
9958
  const audio = audioPath !== null && existsSync18(audioPath) ? audioPath : null;
10000
9959
  await writeJoined(ctx, videoSources, audio, finalPath);
10001
9960
  renameSync7(finalPath, outputPath);
@@ -10006,7 +9965,7 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
10006
9965
  });
10007
9966
  } finally {
10008
9967
  try {
10009
- rmSync11(workDir, { recursive: true, force: true });
9968
+ rmSync12(workDir, { recursive: true, force: true });
10010
9969
  } catch (err) {
10011
9970
  log.warn("[assemble] failed to remove work dir", {
10012
9971
  workDir,
@@ -10023,8 +9982,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
10023
9982
  };
10024
9983
  }
10025
9984
  function readAssemblePlan(planDir, chunkPaths) {
10026
- const planJsonPath = join26(planDir, "plan.json");
10027
- const chunksJsonPath = join26(planDir, "meta", "chunks.json");
9985
+ const planJsonPath = join25(planDir, "plan.json");
9986
+ const chunksJsonPath = join25(planDir, "meta", "chunks.json");
10028
9987
  if (!existsSync18(planJsonPath)) {
10029
9988
  throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
10030
9989
  }
@@ -10055,10 +10014,9 @@ function readAssemblePlan(planDir, chunkPaths) {
10055
10014
  function videoInputArgs(ctx, videoSources) {
10056
10015
  return videoSources.length === 1 ? ["-i", videoSources[0]] : concatDemuxerInput(videoSources, ctx.workDir, ctx.fpsArg);
10057
10016
  }
10058
- async function reencodeCfr(ctx, chunkPaths, encoder) {
10059
- const cfrOutputPath = join26(ctx.workDir, `cfr.${ctx.format}`);
10060
- const cfrArgs = [
10061
- ...videoInputArgs(ctx, chunkPaths),
10017
+ function cfrEncodeArgs(inputArgs, encoder, fpsArg) {
10018
+ const args = [
10019
+ ...inputArgs,
10062
10020
  "-c:v",
10063
10021
  "libx264",
10064
10022
  ...cfrRateControlArgs(encoder),
@@ -10067,8 +10025,14 @@ async function reencodeCfr(ctx, chunkPaths, encoder) {
10067
10025
  "-fps_mode",
10068
10026
  "cfr",
10069
10027
  "-r",
10070
- ctx.fpsArg
10028
+ fpsArg
10071
10029
  ];
10030
+ appendSoftwareEncoderThreadsArg(args);
10031
+ return args;
10032
+ }
10033
+ async function reencodeCfr(ctx, chunkPaths, encoder) {
10034
+ const cfrOutputPath = join25(ctx.workDir, `cfr.${ctx.format}`);
10035
+ const cfrArgs = cfrEncodeArgs(videoInputArgs(ctx, chunkPaths), encoder, ctx.fpsArg);
10072
10036
  appendRenderProvenanceArgs2(cfrArgs, cfrOutputPath);
10073
10037
  cfrArgs.push("-y", cfrOutputPath);
10074
10038
  await ctx.timed(
@@ -10094,7 +10058,7 @@ async function writeJoined(ctx, videoSources, audioPath, finalPath) {
10094
10058
  );
10095
10059
  return;
10096
10060
  }
10097
- const paddedAudioPath = join26(ctx.workDir, "audio-padded.m4a");
10061
+ const paddedAudioPath = join25(ctx.workDir, "audio-padded.m4a");
10098
10062
  const padTrimResult = await ctx.timed(
10099
10063
  "audioMs",
10100
10064
  () => padOrTrimAudioToVideoFrameCount({
@@ -10134,7 +10098,7 @@ async function writeJoined(ctx, videoSources, audioPath, finalPath) {
10134
10098
  }
10135
10099
  }
10136
10100
  function concatDemuxerInput(chunkPaths, workDir, fpsArg) {
10137
- const listPath = join26(workDir, "concat-list.txt");
10101
+ const listPath = join25(workDir, "concat-list.txt");
10138
10102
  const body = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
10139
10103
  writeFileSync9(listPath, `${body}
10140
10104
  `, "utf-8");
@@ -10162,6 +10126,7 @@ async function runAssembleFfmpeg(args, step, signal, timeout) {
10162
10126
  function chunksAreStitchedMp4(planDir, cfr) {
10163
10127
  const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
10164
10128
  if (!chunksCarryAudio && !separateAudio) return false;
10129
+ if (planFormat(planDir) !== "mp4") return false;
10165
10130
  if (cfr) {
10166
10131
  throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks are stitched");
10167
10132
  }
@@ -10170,8 +10135,12 @@ function chunksAreStitchedMp4(planDir, cfr) {
10170
10135
  function stitchesSeparateAudio(planDir) {
10171
10136
  return lockedAudio(planDir).separateAudio === true;
10172
10137
  }
10138
+ function planFormat(planDir) {
10139
+ const plan2 = JSON.parse(readFileSync11(join25(planDir, "plan.json"), "utf-8"));
10140
+ return plan2.dimensions.format;
10141
+ }
10173
10142
  function lockedAudio(planDir) {
10174
- const encoderJsonPath = join26(planDir, "meta", "encoder.json");
10143
+ const encoderJsonPath = join25(planDir, "meta", "encoder.json");
10175
10144
  if (!existsSync18(encoderJsonPath)) return {};
10176
10145
  return JSON.parse(readFileSync11(encoderJsonPath, "utf-8"));
10177
10146
  }
@@ -10181,7 +10150,7 @@ function readCfrEncoder(planDir, format) {
10181
10150
  `[assemble] cfr=true is only supported for format="mp4" (got "${format}"). Stream-copy paths for webm and mov already produce exact avg_frame_rate; cfr re-encode is not needed.`
10182
10151
  );
10183
10152
  }
10184
- const encoderJsonPath = join26(planDir, "meta", "encoder.json");
10153
+ const encoderJsonPath = join25(planDir, "meta", "encoder.json");
10185
10154
  if (!existsSync18(encoderJsonPath)) {
10186
10155
  throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`);
10187
10156
  }
@@ -10194,7 +10163,7 @@ function readCfrEncoder(planDir, format) {
10194
10163
  return encoderJson;
10195
10164
  }
10196
10165
  function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, startTimeMs) {
10197
- if (existsSync18(outputPath)) rmSync11(outputPath, { recursive: true, force: true });
10166
+ if (existsSync18(outputPath)) rmSync12(outputPath, { recursive: true, force: true });
10198
10167
  mkdirSync13(outputPath, { recursive: true });
10199
10168
  let globalIdx = 0;
10200
10169
  for (const chunkDir of chunkPaths) {
@@ -10208,8 +10177,8 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
10208
10177
  throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`);
10209
10178
  }
10210
10179
  for (const frame of frames) {
10211
- const dst = join26(outputPath, formatExportFrameName(globalIdx, "png"));
10212
- cpSync3(join26(chunkDir, frame), dst);
10180
+ const dst = join25(outputPath, formatExportFrameName(globalIdx, "png"));
10181
+ cpSync3(join25(chunkDir, frame), dst);
10213
10182
  globalIdx += 1;
10214
10183
  }
10215
10184
  }
@@ -10219,13 +10188,13 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
10219
10188
  );
10220
10189
  }
10221
10190
  if (audioPath !== null && existsSync18(audioPath)) {
10222
- const sidecar = join26(outputPath, MIXED_AUDIO_FILENAME4);
10191
+ const sidecar = join25(outputPath, MIXED_AUDIO_FILENAME4);
10223
10192
  cpSync3(audioPath, sidecar);
10224
10193
  }
10225
10194
  let fileSize = 0;
10226
10195
  for (const name of readdirSync8(outputPath)) {
10227
10196
  try {
10228
- fileSize += statSync7(join26(outputPath, name)).size;
10197
+ fileSize += statSync7(join25(outputPath, name)).size;
10229
10198
  } catch {
10230
10199
  }
10231
10200
  }
@@ -10239,8 +10208,8 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
10239
10208
 
10240
10209
  // src/services/distributed/renderChunk.ts
10241
10210
  import { randomBytes } from "node:crypto";
10242
- import { existsSync as existsSync19, mkdirSync as mkdirSync14, readFileSync as readFileSync12, readdirSync as readdirSync9, rmSync as rmSync12, writeFileSync as writeFileSync10 } from "node:fs";
10243
- import { join as join27 } from "node:path";
10211
+ import { existsSync as existsSync19, mkdirSync as mkdirSync14, readFileSync as readFileSync12, readdirSync as readdirSync9, rmSync as rmSync13, writeFileSync as writeFileSync10 } from "node:fs";
10212
+ import { join as join26 } from "node:path";
10244
10213
  import {
10245
10214
  assertSwiftShader,
10246
10215
  BROWSER_GPU_NOT_SOFTWARE as BROWSER_GPU_NOT_SOFTWARE4,
@@ -10342,7 +10311,7 @@ function hashChunkOutput(outputPath, kind) {
10342
10311
  if (kind === "file") return sha256Hex(readFileSync12(outputPath));
10343
10312
  const entries = readdirSync9(outputPath).filter((name) => /\.(png|jpg|jpeg)$/i.test(name)).sort();
10344
10313
  const lines = entries.map(
10345
- (name) => `${name}\0${sha256Hex(readFileSync12(join27(outputPath, name)))}`
10314
+ (name) => `${name}\0${sha256Hex(readFileSync12(join26(outputPath, name)))}`
10346
10315
  );
10347
10316
  return sha256Hex(lines.join("\0"));
10348
10317
  }
@@ -10359,9 +10328,9 @@ function resolveLockedVp9CpuUsed(lockedEncoder) {
10359
10328
  async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10360
10329
  const start = Date.now();
10361
10330
  const log = defaultLogger;
10362
- const planJsonPath = join27(planDir, "plan.json");
10363
- const encoderJsonPath = join27(planDir, "meta", "encoder.json");
10364
- const chunksJsonPath = join27(planDir, "meta", "chunks.json");
10331
+ const planJsonPath = join26(planDir, "plan.json");
10332
+ const encoderJsonPath = join26(planDir, "meta", "encoder.json");
10333
+ const chunksJsonPath = join26(planDir, "meta", "chunks.json");
10365
10334
  if (!existsSync19(planJsonPath)) {
10366
10335
  throw new RenderChunkValidationError(
10367
10336
  MISSING_PLAN_ARTIFACT,
@@ -10387,7 +10356,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10387
10356
  }
10388
10357
  const encoder = JSON.parse(readFileSync12(encoderJsonPath, "utf-8"));
10389
10358
  const chunks = JSON.parse(readFileSync12(chunksJsonPath, "utf-8"));
10390
- const videosJsonPath = join27(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
10359
+ const videosJsonPath = join26(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
10391
10360
  let planVideos = null;
10392
10361
  if (existsSync19(videosJsonPath)) {
10393
10362
  try {
@@ -10419,7 +10388,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10419
10388
  }
10420
10389
  const range = resolveChunkFrameRange(slice, chunkIndex, options?.frameRange);
10421
10390
  const framesInChunk = range.endFrame - range.startFrame;
10422
- const compiledDir = join27(planDir, "compiled");
10391
+ const compiledDir = join26(planDir, "compiled");
10423
10392
  if (!existsSync19(compiledDir)) {
10424
10393
  throw new RenderChunkValidationError(
10425
10394
  MISSING_PLAN_ARTIFACT,
@@ -10477,7 +10446,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10477
10446
  };
10478
10447
  const workDir = `${outputChunkPath}.work.${process.pid}.${randomBytes(4).toString("hex")}`;
10479
10448
  mkdirSync14(workDir, { recursive: true });
10480
- const framesDir = join27(workDir, "captured-frames");
10449
+ const framesDir = join26(workDir, "captured-frames");
10481
10450
  mkdirSync14(framesDir, { recursive: true });
10482
10451
  const videoFramesDir = `${workDir}.video-frames`;
10483
10452
  const chunkFrameNumbers = Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i);
@@ -10502,7 +10471,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10502
10471
  dimensions: plan2.dimensions,
10503
10472
  encoder,
10504
10473
  videoCount: planVideos?.videos.length ?? 0,
10505
- html: readFileSync12(join27(compiledDir, "index.html"), "utf-8"),
10474
+ html: readFileSync12(join26(compiledDir, "index.html"), "utf-8"),
10506
10475
  // The plan ships a deferred source only to chunks that show it; don't wait on the rest.
10507
10476
  skipReadinessVideoIds: deferredVideos.filter(
10508
10477
  (video) => !shownFrames.get(video.videoId)?.size || remoteVideoIds.has(video.videoId)
@@ -10678,7 +10647,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10678
10647
  return observedMode;
10679
10648
  },
10680
10649
  resetForScreenshotRetry: () => {
10681
- rmSync12(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10650
+ rmSync13(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10682
10651
  mkdirSync14(framesDir, { recursive: true });
10683
10652
  capturePerfs.length = 0;
10684
10653
  captureAttempts.length = 0;
@@ -10716,7 +10685,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10716
10685
  if (isPngSequence) {
10717
10686
  if (!existsSync19(outputChunkPath)) mkdirSync14(outputChunkPath, { recursive: true });
10718
10687
  } else {
10719
- const outDir = join27(outputChunkPath, "..");
10688
+ const outDir = join26(outputChunkPath, "..");
10720
10689
  if (!existsSync19(outDir)) mkdirSync14(outDir, { recursive: true });
10721
10690
  }
10722
10691
  const encodeStarted = Date.now();
@@ -10771,8 +10740,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10771
10740
  fps: { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen },
10772
10741
  timeoutMs: cfg.ffmpegProcessTimeout
10773
10742
  });
10774
- rmSync12(videoOnlyPath, { force: true });
10775
- rmSync12(audioScratchPath, { force: true });
10743
+ rmSync13(videoOnlyPath, { force: true });
10744
+ rmSync13(audioScratchPath, { force: true });
10776
10745
  }
10777
10746
  encodeStageMs = Date.now() - encodeStarted;
10778
10747
  } finally {
@@ -10818,8 +10787,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10818
10787
  writeFileSync10(perfPath, `${JSON.stringify(perfPayload, null, 2)}
10819
10788
  `, "utf-8");
10820
10789
  try {
10821
- rmSync12(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10822
- rmSync12(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10790
+ rmSync13(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10791
+ rmSync13(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10823
10792
  } catch (err) {
10824
10793
  log.warn("[renderChunk] failed to remove work dir", {
10825
10794
  workDir,
@@ -10848,18 +10817,18 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10848
10817
 
10849
10818
  // src/services/distributed/planV2Execution.ts
10850
10819
  async function renderChunkV2(planV2Dir, chunkIndex, outputChunkPath, options) {
10851
- const workRoot = mkdtempSync7(join28(tmpdir2(), "hf-plan-v2-chunk-"));
10852
- const materializedPlanDir = join28(workRoot, "plan");
10820
+ const workRoot = mkdtempSync7(join27(tmpdir2(), "hf-plan-v2-chunk-"));
10821
+ const materializedPlanDir = join27(workRoot, "plan");
10853
10822
  try {
10854
10823
  materializePlanV2Target(planV2Dir, { role: "chunk", chunkIndex }, materializedPlanDir);
10855
10824
  return await renderChunk(materializedPlanDir, chunkIndex, outputChunkPath, options);
10856
10825
  } finally {
10857
- rmSync13(workRoot, { recursive: true, force: true });
10826
+ rmSync14(workRoot, { recursive: true, force: true });
10858
10827
  }
10859
10828
  }
10860
10829
  async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
10861
- const workRoot = mkdtempSync7(join28(tmpdir2(), "hf-plan-v2-assembler-"));
10862
- const materializedPlanDir = join28(workRoot, "plan");
10830
+ const workRoot = mkdtempSync7(join27(tmpdir2(), "hf-plan-v2-assembler-"));
10831
+ const materializedPlanDir = join27(workRoot, "plan");
10863
10832
  try {
10864
10833
  const materialized = materializePlanV2Target(
10865
10834
  planV2Dir,
@@ -10874,7 +10843,7 @@ async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
10874
10843
  options
10875
10844
  );
10876
10845
  } finally {
10877
- rmSync13(workRoot, { recursive: true, force: true });
10846
+ rmSync14(workRoot, { recursive: true, force: true });
10878
10847
  }
10879
10848
  }
10880
10849
 
@@ -10921,6 +10890,9 @@ function validateDistributedRenderConfig(config) {
10921
10890
  }
10922
10891
  validateIntDimension("config.width", config.width);
10923
10892
  validateIntDimension("config.height", config.height);
10893
+ if (config.pageMotionBlur !== void 0 && config.pageMotionBlur !== "off") {
10894
+ throw new InvalidConfigError("config.pageMotionBlur", 'must be "off" when provided');
10895
+ }
10924
10896
  if (!ALLOWED_FORMATS.includes(config.format)) {
10925
10897
  throw new InvalidConfigError(
10926
10898
  "config.format",
@@ -11123,7 +11095,7 @@ function validateIntDimension(field, value) {
11123
11095
  // src/services/distributed/projectHash.ts
11124
11096
  import { closeSync as closeSync5, openSync as openSync5, readdirSync as readdirSync10, readSync as readSync3, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
11125
11097
  import { createHash as createHash6 } from "node:crypto";
11126
- import { join as join29 } from "node:path";
11098
+ import { join as join28 } from "node:path";
11127
11099
  var SITE_ID_SCHEME = "site-v2\0";
11128
11100
  var READ_CHUNK_BYTES = 256 * 1024;
11129
11101
  function listSiteFiles(projectDir) {
@@ -11132,7 +11104,7 @@ function listSiteFiles(projectDir) {
11132
11104
  function walk(dir, rel) {
11133
11105
  for (const name of readdirSync10(dir).sort()) {
11134
11106
  if (rel === "" && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(name)) continue;
11135
- const full = join29(dir, name);
11107
+ const full = join28(dir, name);
11136
11108
  const relPath = rel === "" ? name : `${rel}/${name}`;
11137
11109
  let stat;
11138
11110
  try {
@@ -11162,7 +11134,7 @@ function hashProjectDir(projectDir, files = listSiteFiles(projectDir)) {
11162
11134
  for (const rel of files) {
11163
11135
  hash.update(rel);
11164
11136
  hash.update("\0");
11165
- const fd = openSync5(join29(projectDir, rel), "r");
11137
+ const fd = openSync5(join28(projectDir, rel), "r");
11166
11138
  try {
11167
11139
  let read;
11168
11140
  while ((read = readSync3(fd, chunk, 0, chunk.length, null)) > 0) {