@hyperframes/producer 0.8.185 → 0.8.188

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
@@ -2255,7 +2255,10 @@ function stripCompilerMountBootstrap(source) {
2255
2255
  ""
2256
2256
  );
2257
2257
  }
2258
- function detectRenderModeHints(html) {
2258
+ function runtimeRoot(document2) {
2259
+ return document2.querySelector('[data-composition-id][data-root="true"]') ?? document2.querySelector("[data-composition-id]");
2260
+ }
2261
+ function detectRenderModeHints(html, pageMotionBlur) {
2259
2262
  const reasons = [];
2260
2263
  const { document: document2 } = parseHTML4(html);
2261
2264
  if (document2.querySelector("canvas[layoutsubtree]")) {
@@ -2263,6 +2266,11 @@ function detectRenderModeHints(html) {
2263
2266
  code: "htmlInCanvas",
2264
2267
  message: "Detected html-in-canvas API (layoutsubtree canvas). Chrome does not support concurrent drawElementImage across multiple workers; render is pinned to a single worker."
2265
2268
  });
2269
+ } else if (pageMotionBlur !== "off" && runtimeRoot(document2)?.getAttribute("data-motion-blur") === "on") {
2270
+ reasons.push({
2271
+ code: "captureMotionBlur",
2272
+ message: 'Detected capture motion blur (data-motion-blur="on"), which draws each frame with html-in-canvas; routed through screenshot capture mode.'
2273
+ });
2266
2274
  }
2267
2275
  if (document2.querySelector("iframe")) {
2268
2276
  reasons.push({
@@ -3294,7 +3302,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
3294
3302
  /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
3295
3303
  "$1"
3296
3304
  );
3297
- const renderModeHints = detectRenderModeHints(sanitizedHtml);
3305
+ const renderModeHints = detectRenderModeHints(sanitizedHtml, options.pageMotionBlur);
3298
3306
  const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
3299
3307
  const usesThreeDTransforms = detectThreeDTransformUsage(sanitizedHtml);
3300
3308
  const usesMixBlendMode = detectMixBlendModeUsage(sanitizedHtml);
@@ -3436,7 +3444,9 @@ async function discoverMediaFromBrowser(page) {
3436
3444
  const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
3437
3445
  const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
3438
3446
  if (!id) return;
3439
- 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 || "";
3440
3450
  const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
3441
3451
  const endRaw = htmlEl.getAttribute("data-end");
3442
3452
  const durationRaw = htmlEl.getAttribute("data-duration");
@@ -3779,6 +3789,7 @@ async function runCompileStage(input) {
3779
3789
  abortSignal,
3780
3790
  variables: input.variables,
3781
3791
  urlByAbsentLocalPath: input.urlByAbsentLocalPath,
3792
+ pageMotionBlur: input.pageMotionBlur,
3782
3793
  animatedGifCacheDir: cfg.extractCacheDir ? join6(cfg.extractCacheDir, "animated-gif") : void 0,
3783
3794
  ffmpegProcessTimeout: cfg.ffmpegProcessTimeout
3784
3795
  });
@@ -4075,6 +4086,7 @@ async function runProbeStage(input) {
4075
4086
  variables: job.config.variables,
4076
4087
  deviceScaleFactor,
4077
4088
  motionBlur,
4089
+ pageMotionBlur: job.config.pageMotionBlur,
4078
4090
  requiresWebGpu: compositionRequiresWebGpu(compiled.html),
4079
4091
  subTimelineWaitMemo
4080
4092
  };
@@ -4937,1241 +4949,1225 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
4937
4949
  }
4938
4950
 
4939
4951
  // src/services/render/stages/audioStage.ts
4940
- import { join as join9 } from "node:path";
4952
+ import { join as join10 } from "node:path";
4953
+ import { rmSync as rmSync5 } from "node:fs";
4941
4954
  import {
4942
4955
  MIXED_AUDIO_FILENAME,
4943
4956
  audioFailureDiagnostics,
4944
4957
  processCompositionAudio
4945
4958
  } from "@hyperframes/engine";
4946
- function triggerFirst(failures, trigger) {
4947
- 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;
4948
4989
  }
4949
- async function runAudioStage(input) {
4950
- const {
4951
- projectDir,
4952
- workDir,
4953
- compiledDir,
4954
- duration,
4955
- ffmpegProcessTimeout,
4956
- audioGain,
4957
- audios,
4958
- masterFxChain,
4959
- urlByAbsentLocalPath,
4960
- keepEncoderInputAt,
4961
- abortSignal,
4962
- assertNotAborted,
4963
- log
4964
- } = input;
4965
- const stage3Start = Date.now();
4966
- const audioOutputPath = join9(workDir, MIXED_AUDIO_FILENAME);
4967
- let hasAudio = false;
4968
- let audioError;
4969
- let audioFailures;
4970
- if (audios.length > 0) {
4971
- let audioResult;
4972
- try {
4973
- audioResult = await processCompositionAudio(
4974
- audios,
4975
- projectDir,
4976
- join9(workDir, "audio-work"),
4977
- audioOutputPath,
4978
- duration,
4979
- abortSignal,
4980
- {
4981
- ffmpegProcessTimeout,
4982
- audioGain,
4983
- masterFxChain,
4984
- urlByAbsentLocalPath,
4985
- keepEncoderInputAt
4986
- },
4987
- compiledDir
4988
- );
4989
- } catch (err) {
4990
- assertNotAborted();
4991
- const detail = err instanceof Error ? err.message : String(err);
4992
- return {
4993
- audioOutputPath,
4994
- hasAudio: false,
4995
- audioProcessMs: Date.now() - stage3Start,
4996
- audioError: detail,
4997
- // Synthesised rather than left undefined. The warning policy reads
4998
- // owner, retryability, reason and stage off this list, so reporting the
4999
- // FATAL failure — the one that took the whole mix down — with an empty
5000
- // one gave it strictly less classification than a single dropped track
5001
- // gets, which is the opposite of what this stage exists to do.
5002
- // "internal" is the honest bucket: the stages enumerate ffmpeg steps
5003
- // and this is the FX render, which is none of them.
5004
- audioFailures: [
5005
- {
5006
- stage: "internal",
5007
- reason: "internal",
5008
- owner: "system",
5009
- retryable: false,
5010
- detail: detail.slice(0, 2e3)
5011
- }
5012
- ]
5013
- };
5014
- }
5015
- assertNotAborted();
5016
- const interrupted = audioResult.failures?.find(
5017
- (failure) => failure.reason === "external_interruption"
5018
- );
5019
- if (interrupted) {
5020
- throw encoderFailureError("Audio processing failed", {
5021
- error: interrupted.detail,
5022
- failureReason: "external_interruption",
5023
- audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
5024
- });
5025
- }
5026
- hasAudio = audioResult.success;
5027
- audioFailures = audioResult.failures;
5028
- if (!hasAudio) {
5029
- audioError = audioResult.error ?? "audio mix failed for an unknown reason";
5030
- for (const failure of audioResult.failures ?? []) {
5031
- log?.warn("Audio track failed", {
5032
- elementId: failure.elementId,
5033
- stage: failure.stage,
5034
- reason: failure.reason,
5035
- owner: failure.owner,
5036
- retryable: failure.retryable,
5037
- detail: failure.detail
5038
- });
5039
- }
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);
5040
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)}`);
5041
5011
  }
5042
- const audioProcessMs = Date.now() - stage3Start;
5012
+ const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
5043
5013
  return {
5044
- audioOutputPath,
5045
- hasAudio,
5046
- audioProcessMs,
5047
- audioError,
5048
- audioFailures
5014
+ peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
5015
+ firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
5016
+ laterBlocks: checkedDbfs(laterBlocks)
5049
5017
  };
5050
5018
  }
5051
-
5052
- // src/services/render/stages/captureStage.ts
5053
- import { existsSync as existsSync8, readdirSync, statfsSync as statfsSync2 } from "node:fs";
5054
- import { join as join10 } from "node:path";
5055
- import {
5056
- classifyCaptureFailure as classifyCaptureFailure2,
5057
- frameFileExtension,
5058
- captureFrame,
5059
- captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
5060
- verifyDiskDrawElementSamples,
5061
- writeCapturedFrame,
5062
- closeCaptureSession as closeCaptureSession5,
5063
- completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
5064
- createCaptureSession as createCaptureSession5,
5065
- getCapturePerfSummary as getCapturePerfSummary3,
5066
- initializeSession as initializeSession5,
5067
- prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
5068
- } from "@hyperframes/engine";
5069
- function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
5070
- return workerCount > 1;
5071
- }
5072
- var JPEG_RAW_RGBA_DIVISOR = 8;
5073
- var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
5074
- var DISK_PROJECTION_SAMPLE_FRAMES = 10;
5075
- function estimateDiskCaptureBytes(totalFrames, captureOptions) {
5076
- const scale = captureOptions.deviceScaleFactor ?? 1;
5077
- const outputWidth = Math.ceil(captureOptions.width * scale);
5078
- const outputHeight = Math.ceil(captureOptions.height * scale);
5079
- const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
5080
- return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
5081
- rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
5082
- ) : rawBytes;
5083
- }
5084
- function freeDiskBytesAt(path) {
5085
- try {
5086
- const stat = statfsSync2(path);
5087
- return stat.bavail * stat.bsize;
5088
- } catch {
5089
- return null;
5090
- }
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();
5091
5048
  }
5092
- function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
5093
- const freeBytes = freeDiskBytes(framesDir);
5094
- const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
5095
- if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
5096
- return { available: true, estimatedBytes, freeBytes };
5097
- }
5098
- 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)}`;
5099
5057
  }
5100
- function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
5101
- const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
5102
- if (workerCount > 1 && oneWorker.enabled) {
5103
- return `This render stores frames because it captures with ${workerCount} workers. Free up disk space, or re-run this render with --workers 1 to stream frames into the encoder instead. One worker is slower, so keep the default worker count for renders that fit on disk.`;
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
+ };
5104
5067
  }
5105
- switch (oneWorker.reason) {
5106
- case "format_excluded":
5107
- return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
5108
- case "disabled_by_config":
5109
- return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
5110
- case "duration_cap":
5111
- return "The streaming duration cap (PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED=true) sends renders longer than PRODUCER_STREAMING_ENCODE_MAX_DURATION_SECONDS to disk at any worker count. Free up disk space, unset the cap, or use --low-memory-mode, which streams past it on one worker.";
5112
- default:
5113
- return "Free up disk space.";
5068
+ if (delta > 0) {
5069
+ return {
5070
+ operation: "pad",
5071
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5072
+ cleanupPaths: []
5073
+ };
5114
5074
  }
5075
+ return {
5076
+ operation: "trim",
5077
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5078
+ cleanupPaths: []
5079
+ };
5115
5080
  }
5116
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
5117
- const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
5118
- return new Error(
5119
- `Disk capture may need ~${(needBytes / 1e6).toFixed(1)} MB of temporary frame storage${basisNote}, but only ${(freeBytes / 1e6).toFixed(1)} MB is free at ${framesDir}. Disk capture stores every frame as an image file (JPEG, or PNG for alpha). ${remedy}`
5120
- );
5081
+ function formatSeconds(sec) {
5082
+ return sec.toFixed(6);
5121
5083
  }
5122
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
5123
- const headroom = inspectDiskCaptureHeadroom(
5124
- framesDir,
5125
- totalFrames,
5126
- captureOptions,
5127
- freeDiskBytes
5128
- );
5129
- if (headroom.available) return;
5130
- throw diskCaptureShortfallError(
5131
- headroom.estimatedBytes,
5132
- headroom.freeBytes,
5133
- framesDir,
5134
- "estimate",
5135
- remedy
5136
- );
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);
5137
5088
  }
5138
- function measureCaptureFrameBytes(workDir, framesDir) {
5139
- let total = sampleDirectoryBytes(framesDir);
5140
- if (!existsSync8(workDir)) return total;
5141
- for (const attempt of readdirSync(workDir)) {
5142
- if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
5143
- total += sampleDirectoryBytes(join10(workDir, attempt));
5144
- continue;
5145
- }
5146
- if (!/^capture-attempt-\d+$/.test(attempt)) continue;
5147
- const attemptDir = join10(workDir, attempt);
5148
- for (const name of readdirSync(attemptDir)) {
5149
- if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join10(attemptDir, name));
5150
- }
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
+ );
5151
5111
  }
5152
- return total;
5153
- }
5154
- function createDiskCaptureProjection(input) {
5155
- const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
5156
- let checked = false;
5157
- return (capturedFrames) => {
5158
- if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
5159
- const measured = measureBytes();
5160
- if (measured <= 0) return;
5161
- checked = true;
5162
- const remaining = totalFrames - capturedFrames;
5163
- const freeBytes = freeDiskBytes(framesDir);
5164
- if (remaining <= 0 || freeBytes === null) return;
5165
- const needBytes = Math.ceil(measured / capturedFrames * remaining);
5166
- if (needBytes > freeBytes * 0.9) {
5167
- throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
5168
- }
5169
- };
5170
- }
5171
- async function runCaptureStage(input) {
5172
- const {
5173
- fileServer,
5174
- workDir,
5175
- framesDir,
5176
- job,
5177
- totalFrames,
5178
- cfg,
5179
- plan: plan2,
5180
- log,
5181
- captureAttempts,
5182
- buildCaptureOptions,
5183
- createRenderVideoFrameInjector,
5184
- abortSignal,
5185
- onProgress,
5186
- frameRange,
5187
- dedupPerfs
5188
- } = input;
5189
- let { probeSession } = input;
5190
- let { workerCount } = plan2;
5191
- const { forceScreenshot } = plan2;
5192
- let lastBrowserConsole = [];
5193
- let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
5194
- const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
5195
- if (frameRange !== void 0) {
5196
- if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
5197
- throw new Error(
5198
- `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
5199
- );
5200
- }
5201
- const rangeFrames = frameRange.endFrame - frameRange.startFrame;
5202
- if (rangeFrames !== totalFrames) {
5203
- throw new Error(
5204
- `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
5205
- );
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
+ }
5206
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 });
5207
5166
  }
5208
- const captureOptions = buildCaptureOptions();
5209
- const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
5210
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
5211
- const checkDiskProjection = createDiskCaptureProjection({
5212
- framesDir,
5213
- totalFrames,
5214
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
5215
- remedy
5216
- });
5217
- if (workerCount > 1) {
5218
- const attempts = await executeDiskCaptureWithAdaptiveRetry({
5219
- serverUrl: fileServer.url,
5220
- workDir,
5221
- framesDir,
5222
- totalFrames,
5223
- initialWorkerCount: workerCount,
5224
- allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
5225
- frameExt: frameFileExtension(captureOptions.format),
5226
- captureOptions,
5227
- createBeforeCaptureHook: createRenderVideoFrameInjector,
5228
- abortSignal,
5229
- frameRangeStart: frameRange?.startFrame,
5230
- dedupPerfs,
5231
- onProgress: (progress) => {
5232
- if (progress.latestWorkerPhase) {
5233
- if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
5234
- return;
5235
- }
5236
- if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
5237
- job.framesRendered = progress.capturedFrames;
5238
- checkDiskProjection(progress.capturedFrames);
5239
- const frameProgress = progress.capturedFrames / progress.totalFrames;
5240
- reportFrameProgress(
5241
- job,
5242
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
5243
- Math.round(25 + frameProgress * 45),
5244
- onProgress,
5245
- progress.capturedFrames,
5246
- progress.totalFrames
5247
- );
5248
- },
5249
- cfg: captureCfg,
5250
- 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
5251
5175
  });
5252
- captureAttempts.push(...attempts);
5253
- const lastAttempt = attempts[attempts.length - 1];
5254
- if (lastAttempt) {
5255
- workerCount = lastAttempt.workers;
5256
- }
5257
- if (probeSession) {
5258
- captureBeyondViewport = probeSession.options.captureBeyondViewport;
5259
- lastBrowserConsole = probeSession.browserConsoleBuffer;
5260
- await closeCaptureSession5(probeSession);
5261
- 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
+ };
5262
5186
  }
5263
- } else {
5264
- const sequential = await runSequentialDiskCapture(
5265
- input,
5266
- captureOptions,
5267
- captureCfg,
5268
- checkDiskProjection
5269
- );
5270
- probeSession = null;
5271
- lastBrowserConsole = sequential.lastBrowserConsole;
5272
- captureBeyondViewport = sequential.captureBeyondViewport;
5187
+ loweredDb = correction.loweredDb ?? 0;
5273
5188
  }
5274
- 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
+ };
5275
5197
  }
5276
- async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
5277
- const {
5278
- fileServer,
5279
- framesDir,
5280
- totalFrames,
5281
- log,
5282
- createRenderVideoFrameInjector,
5283
- abortSignal,
5284
- assertNotAborted,
5285
- frameRange
5286
- } = input;
5287
- let { probeSession } = input;
5288
- let lastBrowserConsole = [];
5289
- let captureBeyondViewport;
5290
- const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
5291
- const rangeFrames = rangeEnd - rangeStart;
5292
- const frameExt = frameFileExtension(captureOptions.format);
5293
- let transientRetriesUsed = 0;
5294
- let resumeFrom = 0;
5295
- while (true) {
5296
- const videoInjector = createRenderVideoFrameInjector();
5297
- const session = probeSession ?? await createCaptureSession5(
5298
- fileServer.url,
5299
- framesDir,
5300
- captureOptions,
5301
- videoInjector,
5302
- captureCfg
5303
- );
5304
- captureBeyondViewport = session.options.captureBeyondViewport;
5305
- try {
5306
- if (probeSession) {
5307
- prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
5308
- 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" };
5309
5223
  }
5310
- await ensureSessionReady(session);
5311
- assertNotAborted();
5312
- lastBrowserConsole = session.browserConsoleBuffer;
5313
- await captureSessionFrames(session, {
5314
- input,
5315
- rangeStart,
5316
- rangeEnd,
5317
- resumeFrom,
5318
- checkDiskProjection
5319
- });
5320
- break;
5321
- } catch (error) {
5322
- lastBrowserConsole = session.browserConsoleBuffer;
5323
- const resume = planTransientResume(error, {
5324
- abortSignal,
5325
- rangeFrames,
5326
- framesDir,
5327
- frameExt,
5328
- retriesUsed: transientRetriesUsed
5329
- });
5330
- if (resume !== null) {
5331
- transientRetriesUsed++;
5332
- resumeFrom = resume;
5333
- log.warn(
5334
- "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
5335
- {
5336
- resumeFrom,
5337
- rangeFrames,
5338
- transientRetriesUsed,
5339
- error: error instanceof Error ? error.message : String(error)
5340
- }
5341
- );
5342
- 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)) };
5343
5228
  }
5344
- throw wrapCaptureStageError(error, lastBrowserConsole);
5345
- } finally {
5346
- lastBrowserConsole = session.browserConsoleBuffer;
5347
- 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;
5348
5249
  }
5349
- }
5350
- return { lastBrowserConsole, captureBeyondViewport };
5351
- }
5352
- function resolveCaptureRange(frameRange, totalFrames) {
5353
- return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
5354
- }
5355
- function planTransientResume(error, ctx) {
5356
- const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
5357
- const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
5358
- const [firstMissing] = missing;
5359
- if (!firstMissing) return null;
5360
- return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
5361
- }
5362
- async function ensureSessionReady(session) {
5363
- if (!session.isInitialized) {
5364
- await initializeSession5(session);
5365
- return;
5366
- }
5367
- if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
5368
- 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 });
5369
5264
  }
5370
5265
  }
5371
- async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
5372
- const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
5373
- const rangeFrames = rangeEnd - rangeStart;
5374
- const reportFrame = (fileIndex) => {
5375
- job.framesRendered = fileIndex + 1;
5376
- checkDiskProjection(fileIndex + 1);
5377
- reportFrameProgress(
5378
- job,
5379
- `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
5380
- Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
5381
- onProgress,
5382
- fileIndex + 1,
5383
- rangeFrames
5384
- );
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
5385
5274
  };
5386
- if (session.workerEncodeEnabled) {
5387
- let prev = null;
5388
- const drainPrev = async () => {
5389
- if (!prev) return;
5390
- assertNotAborted();
5391
- const buf = await prev.encodeResult;
5392
- writeCapturedFrame(session, prev.fileIndex, buf);
5393
- reportFrame(prev.fileIndex);
5394
- };
5395
- for (let i = resumeFrom; i < rangeFrames; i++) {
5396
- assertNotAborted();
5397
- const absoluteIdx = rangeStart + i;
5398
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
5399
- const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
5400
- await drainPrev();
5401
- prev = { fileIndex: i, encodeResult };
5402
- }
5403
- await drainPrev();
5404
- } else {
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
- await captureFrame(session, i, time);
5410
- reportFrame(i);
5411
- }
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 ?? "") };
5412
5296
  }
5413
- await verifyDiskDrawElementSamples(
5414
- session,
5415
- {
5416
- workerId: 0,
5417
- startFrame: rangeStart + resumeFrom,
5418
- endFrame: rangeEnd,
5419
- outputDir: framesDir,
5420
- outputFrameOffset: rangeStart
5421
- },
5422
- 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
5423
5312
  );
5424
- 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 ?? "") };
5425
5320
  }
5426
-
5427
- // src/services/render/stages/captureHdrStage.ts
5428
- import {
5429
- closeCaptureSession as closeCaptureSession7,
5430
- cloneCaptureWarnings as cloneCaptureWarnings2,
5431
- createCaptureSession as createCaptureSession7,
5432
- initTransparentBackground as initTransparentBackground2,
5433
- initializeSession as initializeSession7,
5434
- spawnStreamingEncoder as spawnStreamingEncoder3
5435
- } from "@hyperframes/engine";
5436
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
5437
-
5438
- // src/services/hdrImageTransferCache.ts
5439
- import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
5440
- var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
5441
-
5442
- // src/services/render/stages/captureHdrSequentialLoop.ts
5443
- import {
5444
- TRANSITIONS,
5445
- crossfade
5446
- } from "@hyperframes/engine";
5447
-
5448
- // src/services/render/stages/captureHdrHybridLoop.ts
5449
- import {
5450
- TRANSITIONS as TRANSITIONS2,
5451
- closeCaptureSession as closeCaptureSession6,
5452
- createCaptureSession as createCaptureSession6,
5453
- createFrameReorderBuffer as createFrameReorderBuffer2,
5454
- crossfade as crossfade2,
5455
- initTransparentBackground,
5456
- initializeSession as initializeSession6
5457
- } from "@hyperframes/engine";
5458
-
5459
- // src/services/render/stages/encodeStage.ts
5460
- import {
5461
- copyFileSync as copyFileSync3,
5462
- existsSync as existsSync9,
5463
- mkdirSync as mkdirSync5,
5464
- readdirSync as readdirSync2,
5465
- readFileSync as readFileSync5,
5466
- rmSync as rmSync4,
5467
- statSync as statSync3,
5468
- writeFileSync as writeFileSync2
5469
- } from "node:fs";
5470
- import { dirname as dirname5, join as join12 } from "node:path";
5471
- import {
5472
- encodeFramesChunkedConcat,
5473
- encodeFramesFromDir,
5474
- formatFfmpegError,
5475
- MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
5476
- resolveConfig,
5477
- runFfmpeg as runFfmpeg3,
5478
- spawnStreamingEncoder as spawnStreamingEncoder4
5479
- } from "@hyperframes/engine";
5480
- import {
5481
- clearGifFramesBeforeNext,
5482
- gifClearsAfterLeavingFrameInPlace
5483
- } from "@hyperframes/core";
5484
-
5485
- // src/services/render/stages/gifEncodeArgs.ts
5486
- import { join as join11 } from "node:path";
5487
- function fpsToFfmpegArg2(fps) {
5488
- 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 };
5489
5329
  }
5490
- var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
5491
- var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
5492
- var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
5493
- function framesInput(input, fpsArg) {
5494
- return [
5495
- ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
5496
- "-framerate",
5497
- fpsArg,
5498
- ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
5499
- "-i",
5500
- join11(input.framesDir, input.framePattern)
5501
- ];
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
+ };
5502
5338
  }
5503
- function buildGifPalettegenArgs(input) {
5504
- const fpsArg = fpsToFfmpegArg2(input.fps);
5505
- const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
5506
- return [
5507
- "-y",
5508
- ...framesInput(input, fpsArg),
5509
- "-vf",
5510
- `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
5511
- input.palettePath
5512
- ];
5339
+ async function defaultRunFfmpeg(args, timeoutMs, signal) {
5340
+ return toStepResult(await runFfmpeg4(args, { signal, timeout: timeoutMs }), timeoutMs);
5513
5341
  }
5514
- function buildGifPaletteuseArgs(input) {
5515
- const fpsArg = fpsToFfmpegArg2(input.fps);
5516
- const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
5517
- return [
5518
- "-y",
5519
- ...framesInput(input, fpsArg),
5520
- "-i",
5521
- input.palettePath,
5522
- "-lavfi",
5523
- `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
5524
- "-loop",
5525
- String(input.loop),
5526
- ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
5527
- input.outputPath
5528
- ];
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
+ }
5529
5393
  }
5530
5394
 
5531
- // src/services/render/stages/encodeStage.ts
5532
- import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
5533
- function resolveGifLoop(loop) {
5534
- const resolved = loop ?? 0;
5535
- if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
5536
- 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 });
5537
5429
  }
5538
- return resolved;
5539
5430
  }
5540
- 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
+ }
5541
5511
  return {
5542
- success: false,
5543
- outputPath,
5544
- durationMs: Date.now() - startTime,
5545
- framesEncoded: 0,
5546
- fileSize: 0,
5547
- error,
5548
- failureReason
5512
+ audioOutputPath,
5513
+ hasAudio,
5514
+ audioError,
5515
+ audioFailures
5549
5516
  };
5550
5517
  }
5551
- async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
5552
- const { outputPath } = argsInput;
5553
- const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
5554
- if (!result.success) {
5555
- const error = formatFfmpegError(result.exitCode, result.stderr);
5556
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
5557
- }
5558
- const gif = readFileSync5(outputPath);
5559
- if (!clearGifFramesBeforeNext(gif)) {
5560
- const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
5561
- 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;
5562
5546
  }
5563
- writeFileSync2(outputPath, gif);
5564
- return null;
5565
5547
  }
5566
- async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
5567
- const startTime = Date.now();
5568
- const files = readdirSync2(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
5569
- const frameCount = files.length;
5570
- if (frameCount === 0) {
5571
- 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.`;
5572
5552
  }
5573
- const argsInput = {
5574
- framesDir,
5575
- framePattern,
5576
- palettePath: input.palettePath,
5577
- outputPath,
5578
- fps: input.fps,
5579
- loop: input.loop,
5580
- preserveAlpha: input.preserveAlpha
5581
- };
5582
- try {
5583
- const run = { signal: input.signal, timeout: input.timeout };
5584
- for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
5585
- const result = await runFfmpeg3(args, run);
5586
- if (!result.success) {
5587
- const error = formatFfmpegError(result.exitCode, result.stderr);
5588
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
5589
- }
5590
- }
5591
- if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync5(outputPath)) !== false) {
5592
- const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
5593
- if (failure) return failure;
5594
- }
5595
- const fileSize = existsSync9(outputPath) ? statSync3(outputPath).size : 0;
5596
- return {
5597
- success: true,
5598
- outputPath,
5599
- durationMs: Date.now() - startTime,
5600
- framesEncoded: frameCount,
5601
- fileSize
5602
- };
5603
- } finally {
5604
- 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.";
5605
5562
  }
5606
5563
  }
5607
- function startEncodeProgress(job, stage, onProgress) {
5608
- const total = job.totalFrames ?? 0;
5609
- updateJobStatus(job, "encoding", stage, 75, onProgress, {
5610
- code: "encode",
5611
- ...total > 0 && { done: 0, total }
5612
- });
5613
- 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
+ );
5614
5569
  }
5615
- function resolveEngineConfig(input) {
5616
- 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;
5617
5585
  }
5618
- function videoEncoderOptions(input, engineCfg) {
5619
- const { job, preset } = input;
5620
- return {
5621
- fps: job.config.fps,
5622
- width: input.width,
5623
- height: input.height,
5624
- codec: preset.codec,
5625
- preset: preset.preset,
5626
- quality: input.effectiveQuality,
5627
- bitrate: input.effectiveBitrate,
5628
- pixelFormat: preset.pixelFormat,
5629
- vp9CpuUsed: engineCfg.vp9CpuUsed,
5630
- useGpu: job.config.useGpu,
5631
- hdr: preset.hdr,
5632
- // Distributed chunk renders pass these so the encoder writes closed-GOP
5633
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
5634
- // renders leave both undefined → preserves the existing open-GOP output.
5635
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
5636
- gopSize: input.gopSize
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
+ }
5637
5601
  };
5638
5602
  }
5639
- async function runEncodeStage(input) {
5603
+ async function runCaptureStage(input) {
5640
5604
  const {
5605
+ fileServer,
5606
+ workDir,
5607
+ framesDir,
5641
5608
  job,
5609
+ totalFrames,
5610
+ cfg,
5611
+ plan: plan2,
5642
5612
  log,
5643
- outputPath,
5644
- framesDir,
5645
- videoOnlyPath,
5646
- needsAlpha,
5647
- hasAudio,
5648
- audioOutputPath,
5649
- isPngSequence,
5650
- isGif,
5651
- enableChunkedEncode,
5652
- chunkedEncodeSize,
5613
+ captureAttempts,
5614
+ buildCaptureOptions,
5615
+ createRenderVideoFrameInjector,
5653
5616
  abortSignal,
5654
- assertNotAborted,
5655
- onProgress
5617
+ onProgress,
5618
+ frameRange,
5619
+ dedupPerfs
5656
5620
  } = input;
5657
- const stage5Start = Date.now();
5658
- if (isPngSequence) {
5659
- const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
5660
- if (!existsSync9(outputPath)) mkdirSync5(outputPath, { recursive: true });
5661
- const captured = readdirSync2(framesDir).filter((name) => name.endsWith(".png")).sort();
5662
- 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) {
5663
5629
  throw new Error(
5664
- `[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.`
5665
5631
  );
5666
5632
  }
5667
- captured.forEach((name, i) => {
5668
- const dst = join12(outputPath, formatExportFrameName(i, "png"));
5669
- copyFileSync3(join12(framesDir, name), dst);
5670
- });
5671
- if (hasAudio && audioOutputPath && existsSync9(audioOutputPath)) {
5672
- copyFileSync3(audioOutputPath, join12(outputPath, MIXED_AUDIO_FILENAME2));
5673
- log.info(
5674
- `[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)}.`
5675
5637
  );
5676
5638
  }
5677
- encoded();
5678
- return { encodeMs: Date.now() - stage5Start };
5679
5639
  }
5680
- const engineCfg = resolveEngineConfig(input);
5681
- if (isGif) {
5682
- const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
5683
- if (hasAudio) {
5684
- log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
5685
- }
5686
- const frameExt2 = frameFileExtension2(input.captureImageFormat);
5687
- const framePattern2 = `frame_%06d.${frameExt2}`;
5688
- const loop = resolveGifLoop(job.config.gifLoop);
5689
- const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
5690
- fps: job.config.fps,
5691
- loop,
5692
- palettePath: join12(dirname5(videoOnlyPath), "gif-palette.png"),
5693
- preserveAlpha: needsAlpha,
5694
- signal: abortSignal,
5695
- 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
5696
5682
  });
5697
- assertNotAborted();
5698
- if (!encodeResult2.success) {
5699
- throw encoderFailureError("Encoding failed", encodeResult2);
5683
+ captureAttempts.push(...attempts);
5684
+ const lastAttempt = attempts[attempts.length - 1];
5685
+ if (lastAttempt) {
5686
+ workerCount = lastAttempt.workers;
5700
5687
  }
5701
- encoded();
5702
- 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;
5703
5704
  }
5704
- const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
5705
- const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
5706
- const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
5707
- const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
5708
- const frameExt = frameFileExtension2(input.captureImageFormat);
5709
- const framePattern = `frame_%06d.${frameExt}`;
5710
- const encoderOpts = videoEncoderOptions(input, engineCfg);
5711
- const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
5712
- framesDir,
5713
- framePattern,
5714
- videoOnlyPath,
5715
- encoderOpts,
5716
- chunkedEncodeSize,
5717
- abortSignal,
5718
- videoEngineCfg,
5719
- onFramesEncoded
5720
- ) : await encodeFramesFromDir(
5705
+ return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
5706
+ }
5707
+ async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
5708
+ const {
5709
+ fileServer,
5721
5710
  framesDir,
5722
- framePattern,
5723
- videoOnlyPath,
5724
- encoderOpts,
5711
+ totalFrames,
5712
+ log,
5713
+ createRenderVideoFrameInjector,
5725
5714
  abortSignal,
5726
- videoEngineCfg,
5727
- onFramesEncoded
5728
- );
5729
- assertNotAborted();
5730
- if (!encodeResult.success) {
5731
- throw encoderFailureError("Encoding failed", encodeResult);
5732
- }
5733
- onFramesEncoded();
5734
- return { encodeMs: Date.now() - stage5Start };
5735
- }
5736
-
5737
- // src/services/render/stages/assembleStage.ts
5738
- import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
5739
-
5740
- // src/services/render/audioPadTrim.ts
5741
- import { spawn } from "node:child_process";
5742
- import { mkdtempSync as mkdtempSync2, renameSync as renameSync2, rmSync as rmSync5 } from "node:fs";
5743
- import { dirname as dirname6, join as join13 } from "node:path";
5744
- import {
5745
- DEFAULT_CONFIG,
5746
- describeFfmpegFailure as describeFfmpegFailure2,
5747
- encodeAac,
5748
- extractAudioMetadata as extractAudioMetadata3,
5749
- getFfprobeBinary,
5750
- ManagedChildProcess,
5751
- runFfmpeg as runFfmpeg5,
5752
- trackChildProcess
5753
- } from "@hyperframes/engine";
5754
- import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
5755
-
5756
- // src/services/render/audioTruePeak.ts
5757
- import {
5758
- aacSegmentCount,
5759
- describeFfmpegFailure,
5760
- extractAudioMetadata as extractAudioMetadata2,
5761
- runFfmpeg as runFfmpeg4
5762
- } from "@hyperframes/engine";
5763
- var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
5764
- function checkedDbfs(value) {
5765
- if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
5766
- throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
5767
- }
5768
- return value;
5769
- }
5770
- async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
5771
- let firstBlock;
5772
- let laterBlocks = Number.NEGATIVE_INFINITY;
5773
- let partialLine = "";
5774
- const readLines = (chunk) => {
5775
- const lines = (partialLine + chunk).split(/\r|\n/);
5776
- partialLine = lines.pop() ?? "";
5777
- for (const line of lines) {
5778
- const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
5779
- if (!ftpk) continue;
5780
- const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
5781
- if (firstBlock === void 0) firstBlock = block;
5782
- 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);
5783
5779
  }
5784
- };
5785
- const result = await runFfmpeg4(
5786
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
5787
- { signal, timeout: timeoutMs, onStderr: readLines }
5788
- );
5789
- if (!result.success) {
5790
- throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
5791
5780
  }
5792
- const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
5793
- return {
5794
- peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
5795
- firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
5796
- laterBlocks: checkedDbfs(laterBlocks)
5797
- };
5781
+ return { lastBrowserConsole, captureBeyondViewport };
5798
5782
  }
5799
- async function measureTruePeakDbfs(audioPath, options) {
5800
- const { timeoutMs, signal } = options;
5801
- const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
5802
- const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
5803
- const block = 4800;
5804
- const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
5805
- if (count === 1) return wholeFile();
5806
- const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
5807
- const peers = new AbortController();
5808
- const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
5809
- const scans = await Promise.all(
5810
- Array.from({ length: count }, (_, k) => {
5811
- const start = k === 0 ? 0 : cut(k) - block;
5812
- const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
5813
- return scanTruePeak(
5814
- audioPath,
5815
- `atrim=start_sample=${start}${end},`,
5816
- timeoutMs,
5817
- segmentSignal
5818
- );
5819
- })
5820
- ).catch((error) => {
5821
- peers.abort();
5822
- throw error;
5823
- });
5824
- const last = scans[count - 1];
5825
- const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
5826
- if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
5827
- return wholeFile();
5783
+ function resolveCaptureRange(frameRange, totalFrames) {
5784
+ return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
5828
5785
  }
5829
-
5830
- // src/services/render/audioPadTrim.ts
5831
- var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
5832
- var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
5833
- var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
5834
- var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
5835
- function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
5836
- 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;
5837
5792
  }
5838
- function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
5839
- const delta = targetDurationSeconds - sourceDurationSeconds;
5840
- const targetSec = formatSeconds(targetDurationSeconds);
5841
- if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
5842
- return {
5843
- operation: "copy",
5844
- steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
5845
- cleanupPaths: []
5846
- };
5793
+ async function ensureSessionReady(session) {
5794
+ if (!session.isInitialized) {
5795
+ await initializeSession5(session);
5796
+ return;
5847
5797
  }
5848
- if (delta > 0) {
5849
- return {
5850
- operation: "pad",
5851
- steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
5852
- cleanupPaths: []
5853
- };
5798
+ if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
5799
+ await completeDeferredDrawElementInit3(session);
5854
5800
  }
5855
- return {
5856
- operation: "trim",
5857
- steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
5858
- cleanupPaths: []
5859
- };
5860
- }
5861
- function formatSeconds(sec) {
5862
- return sec.toFixed(6);
5863
5801
  }
5864
- function sanitizeProbeFailure(reason, paths) {
5865
- const message = reason instanceof Error ? reason.message : String(reason);
5866
- const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
5867
- return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
5868
- }
5869
- async function padOrTrimAudioToVideoFrameCount(input) {
5870
- const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
5871
- const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
5872
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5873
- const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
5874
- const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
5875
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
5876
- const [videoResult, audioResult] = await Promise.allSettled([
5877
- probeVideo(input.videoPath),
5878
- probeAudio(input.audioPath, input.signal)
5879
- ]);
5880
- const probePaths = [input.videoPath, input.audioPath, input.outputPath];
5881
- if (videoResult.status === "rejected") {
5882
- return failResult(
5883
- input.outputPath,
5884
- 0,
5885
- audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
5886
- `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
5887
- videoResult.reason,
5888
- probePaths
5889
- )}`
5890
- );
5891
- }
5892
- if (audioResult.status === "rejected") {
5893
- return failResult(
5894
- input.outputPath,
5895
- 0,
5896
- 0,
5897
- `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
5898
- audioResult.reason,
5899
- probePaths
5900
- )}`
5901
- );
5902
- }
5903
- const videoInfo = videoResult.value;
5904
- const audioInfo = audioResult.value;
5905
- if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
5906
- return failResult(
5907
- input.outputPath,
5908
- 0,
5909
- audioInfo.durationSeconds,
5910
- `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
5911
5815
  );
5912
- }
5913
- const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
5914
- const plan2 = buildPadTrimAudioPlan(
5915
- input.audioPath,
5916
- input.outputPath,
5917
- audioInfo.durationSeconds,
5918
- targetDurationSeconds
5919
- );
5920
- try {
5921
- for (const step of plan2.steps) {
5922
- const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
5923
- if (!ffmpegResult.success) {
5924
- return {
5925
- success: false,
5926
- outputPath: input.outputPath,
5927
- targetDurationSeconds,
5928
- sourceDurationSeconds: audioInfo.durationSeconds,
5929
- operation: plan2.operation,
5930
- error: ffmpegResult.error,
5931
- failureReason: ffmpegResult.failureReason
5932
- };
5933
- }
5934
- }
5935
- } catch (err) {
5936
- return {
5937
- success: false,
5938
- outputPath: input.outputPath,
5939
- targetDurationSeconds,
5940
- sourceDurationSeconds: audioInfo.durationSeconds,
5941
- operation: plan2.operation,
5942
- 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);
5943
5825
  };
5944
- } finally {
5945
- for (const path of plan2.cleanupPaths) rmSync5(path, { force: true });
5946
- }
5947
- let loweredDb = 0;
5948
- if (probeTruePeak) {
5949
- const correction = await enforceAacTruePeak({
5950
- audioPath: input.outputPath,
5951
- targetDurationSeconds,
5952
- signal: input.signal,
5953
- probeTruePeak,
5954
- encode
5955
- });
5956
- if (!correction.success) {
5957
- return {
5958
- success: false,
5959
- outputPath: input.outputPath,
5960
- targetDurationSeconds,
5961
- sourceDurationSeconds: audioInfo.durationSeconds,
5962
- operation: plan2.operation,
5963
- error: correction.error,
5964
- failureReason: correction.failureReason
5965
- };
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);
5966
5842
  }
5967
- loweredDb = correction.loweredDb ?? 0;
5968
5843
  }
5969
- return {
5970
- success: true,
5971
- outputPath: input.outputPath,
5972
- targetDurationSeconds,
5973
- sourceDurationSeconds: audioInfo.durationSeconds,
5974
- operation: plan2.operation,
5975
- ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
5976
- };
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));
5977
5856
  }
5978
- async function constrainAacTruePeak(input) {
5979
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
5980
- return enforceAacTruePeak({
5981
- audioPath: input.audioPath,
5982
- targetDurationSeconds: input.targetDurationSeconds,
5983
- signal: input.signal,
5984
- probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
5985
- encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
5986
- });
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}`;
5987
5920
  }
5988
- async function enforceAacTruePeak(input) {
5989
- let scratchDir;
5990
- try {
5991
- scratchDir = mkdtempSync2(join13(dirname6(input.audioPath), ".true-peak-"));
5992
- const correctedPath = join13(scratchDir, "audio.m4a");
5993
- const measurements = [];
5994
- let attenuationDb = 0;
5995
- let measuredPath = input.audioPath;
5996
- for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
5997
- const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
5998
- measurements.push(
5999
- `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
6000
- );
6001
- if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
6002
- return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
6003
- }
6004
- if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
6005
- if (measuredPath !== correctedPath) return { success: true };
6006
- renameSync2(correctedPath, input.audioPath);
6007
- return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
6008
- }
6009
- if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
6010
- const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
6011
- attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
6012
- const result = await input.encode(
6013
- input.audioPath,
6014
- correctedPath,
6015
- input.targetDurationSeconds,
6016
- aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
6017
- );
6018
- if (!result.success) {
6019
- return {
6020
- success: false,
6021
- error: sanitizeProbeFailure(
6022
- result.error ?? "audioPadTrim: failed to constrain AAC true peak",
6023
- [input.audioPath, correctedPath]
6024
- ),
6025
- failureReason: result.failureReason
6026
- };
6027
- }
6028
- measuredPath = correctedPath;
6029
- }
6030
- return {
6031
- success: false,
6032
- error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
6033
- };
6034
- } catch (err) {
6035
- return {
6036
- success: false,
6037
- error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
6038
- err,
6039
- scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
6040
- )}`
6041
- };
6042
- } finally {
6043
- 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})`);
6044
5968
  }
5969
+ return resolved;
6045
5970
  }
6046
- function failResult(outputPath, target, source, error) {
5971
+ function gifEncodeFailure(startTime, outputPath, error, failureReason) {
6047
5972
  return {
6048
5973
  success: false,
6049
5974
  outputPath,
6050
- targetDurationSeconds: target,
6051
- sourceDurationSeconds: source,
6052
- operation: "copy",
6053
- error
5975
+ durationMs: Date.now() - startTime,
5976
+ framesEncoded: 0,
5977
+ fileSize: 0,
5978
+ error,
5979
+ failureReason
6054
5980
  };
6055
5981
  }
6056
- async function defaultProbeVideoFrameInfo(videoPath, signal) {
6057
- const fastInfo = await runFfprobeJson(
6058
- [
6059
- "-v",
6060
- "error",
6061
- "-select_streams",
6062
- "v:0",
6063
- "-show_entries",
6064
- "stream=nb_frames,r_frame_rate",
6065
- "-of",
6066
- "json",
6067
- "--",
6068
- videoPath
6069
- ],
6070
- signal
6071
- );
6072
- let stream = fastInfo.streams?.[0];
6073
- const fastCount = Number(stream?.nb_frames);
6074
- if (stream && Number.isFinite(fastCount) && fastCount > 0) {
6075
- return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
6076
- }
6077
- const slowInfo = await runFfprobeJson(
6078
- [
6079
- "-v",
6080
- "error",
6081
- "-select_streams",
6082
- "v:0",
6083
- "-count_packets",
6084
- "-show_entries",
6085
- "stream=nb_read_packets,r_frame_rate",
6086
- "-of",
6087
- "json",
6088
- "--",
6089
- videoPath
6090
- ],
6091
- signal
6092
- );
6093
- stream = slowInfo.streams?.[0];
6094
- if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
6095
- const slowCount = Number(stream.nb_read_packets);
6096
- if (!Number.isFinite(slowCount) || slowCount <= 0) {
6097
- throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
6098
- }
6099
- return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
6100
- }
6101
- function parseFrameRate(rate) {
6102
- const [n, d] = rate.split("/");
6103
- const fpsNum = Number(n);
6104
- const fpsDen = d === void 0 ? 1 : Number(d);
6105
- if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
6106
- throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
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);
6107
5988
  }
6108
- return { fpsNum, fpsDen };
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);
5993
+ }
5994
+ writeFileSync2(outputPath, gif);
5995
+ return null;
6109
5996
  }
6110
- async function defaultProbeAudioInfo(audioPath, signal) {
6111
- const metadata = await extractAudioMetadata3(audioPath, { signal });
6112
- return {
6113
- durationSeconds: metadata.durationSeconds,
6114
- sampleRate: metadata.sampleRate,
6115
- channels: metadata.channels,
6116
- 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
6117
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
+ }
6118
6037
  }
6119
- async function defaultRunFfmpeg(args, timeoutMs, signal) {
6120
- 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);
6121
6045
  }
6122
- function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
6123
- return async (inputPath, outputPath, durationSeconds, filter) => {
6124
- const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
6125
- return toStepResult(
6126
- await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
6127
- timeoutMs
6128
- );
6129
- };
6046
+ function resolveEngineConfig(input) {
6047
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
6130
6048
  }
6131
- function toStepResult(result, timeoutMs) {
6132
- if (result.success) return { success: true };
6049
+ function videoEncoderOptions(input, engineCfg) {
6050
+ const { job, preset } = input;
6133
6051
  return {
6134
- success: false,
6135
- error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
6136
- 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
6137
6068
  };
6138
6069
  }
6139
- async function runFfprobeJson(args, signal) {
6140
- if (!args.includes("--")) {
6141
- throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
6142
- }
6143
- const proc = spawn(getFfprobeBinary(), args, {
6144
- stdio: ["ignore", "pipe", "pipe"],
6145
- windowsHide: true
6146
- });
6147
- trackChildProcess(proc);
6148
- let stdout = "";
6149
- proc.stdout.on("data", (data) => {
6150
- stdout += data.toString();
6151
- });
6152
- const managed = new ManagedChildProcess(proc, {
6153
- signal,
6154
- deadlineAtMs: Date.now() + 3e4
6155
- });
6156
- const outcome = await managed.wait();
6157
- if (outcome.reason === "spawn_error") {
6158
- if (outcome.error?.code === "ENOENT") {
6159
- 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
+ );
6160
6097
  }
6161
- 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 };
6162
6110
  }
6163
- if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
6164
- const probed = args[args.length - 1];
6165
- const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
6166
- 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 };
6167
6134
  }
6168
- try {
6169
- return JSON.parse(stdout);
6170
- } catch (err) {
6171
- 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);
6172
6163
  }
6164
+ onFramesEncoded();
6165
+ return { encodeMs: Date.now() - stage5Start };
6173
6166
  }
6174
6167
 
6168
+ // src/services/render/stages/assembleStage.ts
6169
+ import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
6170
+
6175
6171
  // src/services/renderOrchestrator.ts
6176
6172
  function sampleDirectoryBytes(dir) {
6177
6173
  let total = 0;
@@ -6186,7 +6182,7 @@ function sampleDirectoryBytes(dir) {
6186
6182
  continue;
6187
6183
  }
6188
6184
  for (const name of entries) {
6189
- const full = join14(current, name);
6185
+ const full = join13(current, name);
6190
6186
  try {
6191
6187
  const st = statSync4(full);
6192
6188
  if (st.isDirectory()) {
@@ -6271,7 +6267,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
6271
6267
  const ranges = [];
6272
6268
  let rangeStart = null;
6273
6269
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
6274
- const framePath = join14(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6270
+ const framePath = join13(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6275
6271
  const missing = !existsSync10(framePath) || statSync4(framePath).size <= 8;
6276
6272
  if (missing && rangeStart === null) {
6277
6273
  rangeStart = frameIndex;
@@ -6294,7 +6290,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
6294
6290
  workerId,
6295
6291
  startFrame: rangeStart + range.startFrame,
6296
6292
  endFrame: rangeStart + range.endFrame,
6297
- outputDir: join14(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
6293
+ outputDir: join13(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
6298
6294
  outputFrameOffset: rangeStart
6299
6295
  }));
6300
6296
  batches.push(batch);
@@ -6320,7 +6316,7 @@ function isRecoverableParallelCaptureError(error) {
6320
6316
  function countCapturedFrames(totalFrames, framesDir, frameExt) {
6321
6317
  let captured = 0;
6322
6318
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
6323
- const framePath = join14(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6319
+ const framePath = join13(framesDir, formatCaptureFrameName(frameIndex, frameExt));
6324
6320
  if (existsSync10(framePath)) captured++;
6325
6321
  }
6326
6322
  return captured;
@@ -6346,7 +6342,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
6346
6342
  reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
6347
6343
  });
6348
6344
  pendingTransientRetry = false;
6349
- const attemptWorkDir = join14(options.workDir, `capture-attempt-${attempt}`);
6345
+ const attemptWorkDir = join13(options.workDir, `capture-attempt-${attempt}`);
6350
6346
  const batches = missingRanges ? buildMissingFrameRetryBatches(
6351
6347
  missingRanges,
6352
6348
  currentWorkers,
@@ -6525,7 +6521,7 @@ function explainStreamingEncodeGate(cfg, outputFormat, workerCount, durationSeco
6525
6521
  var DEFAULT_SEGMENTED_MIN_SECONDS = Number.POSITIVE_INFINITY;
6526
6522
 
6527
6523
  // src/services/distributed/planFrameCost.ts
6528
- import { join as join17 } from "node:path";
6524
+ import { join as join16 } from "node:path";
6529
6525
  import {
6530
6526
  closeCaptureSession as closeCaptureSession9,
6531
6527
  createCaptureSession as createCaptureSession9,
@@ -6535,7 +6531,7 @@ import {
6535
6531
 
6536
6532
  // src/services/distributed/chunkFrames.ts
6537
6533
  import { existsSync as existsSync12, readdirSync as readdirSync4 } from "node:fs";
6538
- import { extname as extname2, join as join16 } from "node:path";
6534
+ import { extname as extname2, join as join15 } from "node:path";
6539
6535
  import {
6540
6536
  compositionRequiresWebGpu as compositionRequiresWebGpu3,
6541
6537
  createFrameLookupTable as createFrameLookupTable2,
@@ -6545,7 +6541,7 @@ import {
6545
6541
 
6546
6542
  // src/services/distributed/shared.ts
6547
6543
  import { execFile as execFileCallback } from "node:child_process";
6548
- import { dirname as dirname8, join as join15 } from "node:path";
6544
+ import { dirname as dirname8, join as join14 } from "node:path";
6549
6545
  import { existsSync as existsSync11, readFileSync as readFileSync7 } from "node:fs";
6550
6546
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6551
6547
  import { promisify as promisify2 } from "node:util";
@@ -6561,7 +6557,7 @@ function isPlanAudioArtifactPath(path) {
6561
6557
  }
6562
6558
  function resolvePlanAudioPath(planDir) {
6563
6559
  for (const name of [PLAN_AUDIO_RELATIVE_PATH, PLAN_AUDIO_LEGACY_RELATIVE_PATH]) {
6564
- const candidate = join15(planDir, name);
6560
+ const candidate = join14(planDir, name);
6565
6561
  if (existsSync11(candidate)) return candidate;
6566
6562
  }
6567
6563
  return null;
@@ -6806,7 +6802,8 @@ function buildSyntheticRenderJob(input) {
6806
6802
  hdrMode: input.hdrMode,
6807
6803
  strictness: input.strictness,
6808
6804
  producerConfig: input.producerConfig,
6809
- variables: input.variables
6805
+ variables: input.variables,
6806
+ pageMotionBlur: "off"
6810
6807
  };
6811
6808
  return createRenderJob(renderConfig);
6812
6809
  }
@@ -6816,7 +6813,7 @@ function readProducerVersion() {
6816
6813
  const startDir = dirname8(fileURLToPath3(import.meta.url));
6817
6814
  let current = startDir;
6818
6815
  for (let i = 0; i < 10; i++) {
6819
- const candidate = join15(current, "package.json");
6816
+ const candidate = join14(current, "package.json");
6820
6817
  if (existsSync11(candidate)) {
6821
6818
  try {
6822
6819
  const pkg = JSON.parse(readFileSync7(candidate, "utf-8"));
@@ -6869,7 +6866,7 @@ function frameNumberFromFileName(name) {
6869
6866
  function rebuildExtractedFramesFromPlanDir(planDir, videos, indexMode = "dense-v1") {
6870
6867
  const result = [];
6871
6868
  for (const v of videos) {
6872
- const outputDir = join16(planDir, "video-frames", v.videoId);
6869
+ const outputDir = join15(planDir, "video-frames", v.videoId);
6873
6870
  if (!existsSync12(outputDir)) {
6874
6871
  throw new Error(
6875
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.`
@@ -6888,7 +6885,7 @@ function rebuildExtractedFramesFromPlanDir(planDir, videos, indexMode = "dense-v
6888
6885
  if (!frameName) continue;
6889
6886
  const frameNumber = indexMode === "sparse-v2" ? frameNumberFromFileName(frameName) : null;
6890
6887
  const frameIndex = frameNumber === null ? i : frameNumber - 1;
6891
- framePaths.set(frameIndex, join16(outputDir, frameName));
6888
+ framePaths.set(frameIndex, join15(outputDir, frameName));
6892
6889
  }
6893
6890
  result.push({
6894
6891
  videoId: v.videoId,
@@ -6930,6 +6927,7 @@ function buildChunkCaptureOptions(input) {
6930
6927
  skipReadinessVideoIds: input.skipReadinessVideoIds,
6931
6928
  videoMetadataHints: input.videoMetadataHints,
6932
6929
  ...videoCaptureBeyondViewport !== void 0 ? { captureBeyondViewport: videoCaptureBeyondViewport } : {},
6930
+ pageMotionBlur: encoder.pageMotionBlur ?? "distributed-render",
6933
6931
  // A fixed warmup count keeps `beginFrameTimeTicks` host-independent.
6934
6932
  lockWarmupTicks: true,
6935
6933
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
@@ -6977,14 +6975,14 @@ async function extractDeferredVideoFramesForChunk(input) {
6977
6975
  const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
6978
6976
  return Promise.all(
6979
6977
  deferred.map(async (video) => {
6980
- const outputDir = join16(input.outputRoot, video.videoId);
6978
+ const outputDir = join15(input.outputRoot, video.videoId);
6981
6979
  const indexes = [...shown.get(video.videoId) ?? []].sort((a, b) => a - b);
6982
6980
  if (indexes.length === 0) {
6983
6981
  return { ...video, outputDir, framePaths: /* @__PURE__ */ new Map(), ownedByLookup: false };
6984
6982
  }
6985
6983
  const range = video.deferredRange;
6986
6984
  const remoteUrl = remoteSources[range.sourcePath];
6987
- const sourcePath = remoteUrl ?? join16(input.planDir, ...range.sourcePath.split("/"));
6985
+ const sourcePath = remoteUrl ?? join15(input.planDir, ...range.sourcePath.split("/"));
6988
6986
  if (remoteUrl === void 0 && !existsSync12(sourcePath)) {
6989
6987
  throw new RenderChunkValidationError(
6990
6988
  MISSING_PLAN_ARTIFACT,
@@ -7033,12 +7031,12 @@ async function measurePlanSecondsPerFrame(input) {
7033
7031
  planVideos,
7034
7032
  frames: sampled,
7035
7033
  fps: fpsRatio,
7036
- outputRoot: join17(workDir, "frame-cost-video-frames"),
7034
+ outputRoot: join16(workDir, "frame-cost-video-frames"),
7037
7035
  cfg: input.cfg
7038
7036
  })
7039
7037
  ];
7040
7038
  const lookup = extracted.length > 0 ? createFrameLookupTable3(planVideos.videos, extracted, resolveRenderFpsConfig(fpsRatio).value) : null;
7041
- const compiledDir = join17(planDir, "compiled");
7039
+ const compiledDir = join16(planDir, "compiled");
7042
7040
  const fileServer = await createFileServer({
7043
7041
  projectDir: compiledDir,
7044
7042
  compiledDir,
@@ -7050,7 +7048,7 @@ async function measurePlanSecondsPerFrame(input) {
7050
7048
  try {
7051
7049
  session = await createCaptureSession9(
7052
7050
  fileServer.url,
7053
- join17(workDir, "frame-cost"),
7051
+ join16(workDir, "frame-cost"),
7054
7052
  buildChunkCaptureOptions({
7055
7053
  dimensions: { ...input, fpsNum: fps, fpsDen: 1 },
7056
7054
  encoder: input,
@@ -7072,7 +7070,7 @@ async function measurePlanSecondsPerFrame(input) {
7072
7070
 
7073
7071
  // src/services/render/stages/freezePlan.ts
7074
7072
  import { existsSync as existsSync13, mkdirSync as mkdirSync7, readFileSync as readFileSync8, readdirSync as readdirSync5, writeFileSync as writeFileSync4 } from "node:fs";
7075
- 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";
7076
7074
 
7077
7075
  // src/services/distributed/planProtocol.ts
7078
7076
  var PLAN_SCHEMA_VERSION = 1;
@@ -7290,7 +7288,7 @@ function listPlanFiles(planDir) {
7290
7288
  function walk(dir) {
7291
7289
  const entries = readdirSync5(dir, { withFileTypes: true });
7292
7290
  for (const entry of entries) {
7293
- const full = join18(dir, entry.name);
7291
+ const full = join17(dir, entry.name);
7294
7292
  if (entry.isDirectory()) {
7295
7293
  walk(full);
7296
7294
  } else if (entry.isFile()) {
@@ -7331,15 +7329,15 @@ var PLAN_REMOTE_FILES_RELATIVE_PATH = "meta/remote-files.json";
7331
7329
  function writePlanRemoteFiles(planDir, files) {
7332
7330
  if (files.length === 0) return;
7333
7331
  const sorted = [...files].sort((a, b) => a.path < b.path ? -1 : 1);
7334
- 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");
7335
7333
  }
7336
7334
  function readPlanRemoteFiles(planDir) {
7337
- const path = join18(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH);
7335
+ const path = join17(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH);
7338
7336
  return existsSync13(path) ? JSON.parse(readFileSync8(path, "utf-8")) : [];
7339
7337
  }
7340
7338
  function recomputePlanHashFromPlanDir(planDir, digests) {
7341
- const planJsonPath = join18(planDir, "plan.json");
7342
- const encoderJsonPath = join18(planDir, "meta", "encoder.json");
7339
+ const planJsonPath = join17(planDir, "plan.json");
7340
+ const encoderJsonPath = join17(planDir, "meta", "encoder.json");
7343
7341
  if (!existsSync13(planJsonPath)) {
7344
7342
  throw new Error(`[freezePlan] plan.json missing: ${planJsonPath}`);
7345
7343
  }
@@ -7376,18 +7374,18 @@ async function freezePlan(input) {
7376
7374
  if (!existsSync13(planDir)) {
7377
7375
  throw new Error(`[freezePlan] planDir does not exist: ${planDir}`);
7378
7376
  }
7379
- const metaDir = join18(planDir, "meta");
7377
+ const metaDir = join17(planDir, "meta");
7380
7378
  if (!existsSync13(metaDir)) mkdirSync7(metaDir, { recursive: true });
7381
7379
  writeFileSync4(
7382
- join18(metaDir, "composition.json"),
7380
+ join17(metaDir, "composition.json"),
7383
7381
  `${JSON.stringify(composition, null, 2)}
7384
7382
  `,
7385
7383
  "utf-8"
7386
7384
  );
7387
7385
  const encoderForCanonical = stripUndefined(encoder);
7388
7386
  const encoderConfigCanonicalJson = canonicalJsonStringify(encoderForCanonical);
7389
- writeFileSync4(join18(metaDir, "encoder.json"), encoderConfigCanonicalJson, "utf-8");
7390
- 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)}
7391
7389
  `, "utf-8");
7392
7390
  const { compositionHtml, assets } = collectPlanAssetShas(planDir, digests);
7393
7391
  const planHash = computePlanHash({
@@ -7411,7 +7409,7 @@ async function freezePlan(input) {
7411
7409
  duration: durationSeconds,
7412
7410
  hasAudio
7413
7411
  };
7414
- const planJsonPath = join18(planDir, "plan.json");
7412
+ const planJsonPath = join17(planDir, "plan.json");
7415
7413
  writeFileSync4(planJsonPath, `${JSON.stringify(planJson, null, 2)}
7416
7414
  `, "utf-8");
7417
7415
  return { planJsonPath, planHash };
@@ -7455,7 +7453,7 @@ function applyRuntimeEnvSnapshot(snapshot, env = process.env) {
7455
7453
  // src/services/distributed/chunkAudio.ts
7456
7454
  import { closeSync as closeSync4, existsSync as existsSync14, ftruncateSync, mkdirSync as mkdirSync9, openSync as openSync4, readFileSync as readFileSync9 } from "node:fs";
7457
7455
  import { writeFileSync as writeFileSync5, writeSync } from "node:fs";
7458
- import { join as join20 } from "node:path";
7456
+ import { join as join19 } from "node:path";
7459
7457
  import { appendRenderProvenanceArgs, describeFfmpegFailure as describeFfmpegFailure3, runFfmpeg as runFfmpeg6 } from "@hyperframes/engine";
7460
7458
 
7461
7459
  // src/services/distributed/mp4Stitch.ts
@@ -7465,10 +7463,10 @@ import {
7465
7463
  mkdirSync as mkdirSync8,
7466
7464
  mkdtempSync as mkdtempSync3,
7467
7465
  renameSync as renameSync3,
7468
- rmSync as rmSync7
7466
+ rmSync as rmSync8
7469
7467
  } from "node:fs";
7470
7468
  import { open } from "node:fs/promises";
7471
- import { dirname as dirname9, join as join19 } from "node:path";
7469
+ import { dirname as dirname9, join as join18 } from "node:path";
7472
7470
  import { finished, pipeline } from "node:stream/promises";
7473
7471
  function boxAt(src, off, at, limit) {
7474
7472
  let size = src.readUInt32BE(off);
@@ -7834,16 +7832,16 @@ async function writeStitchedMp4(chunkPaths, outputPath, audioPath) {
7834
7832
  const header = audioPath ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
7835
7833
  mkdirSync8(dirname9(outputPath), { recursive: true });
7836
7834
  const workDir = mkdtempSync3(`${outputPath}.stitch-`);
7837
- const out = createWriteStream(join19(workDir, "out.mp4"));
7835
+ const out = createWriteStream(join18(workDir, "out.mp4"));
7838
7836
  try {
7839
7837
  out.write(header);
7840
7838
  for (const [i, path] of sourcePaths.entries()) await copyPayload(path, layouts[i], i, out);
7841
7839
  out.end();
7842
7840
  await finished(out);
7843
- renameSync3(join19(workDir, "out.mp4"), outputPath);
7841
+ renameSync3(join18(workDir, "out.mp4"), outputPath);
7844
7842
  } finally {
7845
7843
  out.destroy();
7846
- rmSync7(workDir, { recursive: true, force: true });
7844
+ rmSync8(workDir, { recursive: true, force: true });
7847
7845
  }
7848
7846
  }
7849
7847
 
@@ -7934,7 +7932,7 @@ async function writeChunkAudioSlices(input) {
7934
7932
  });
7935
7933
  const bytes = readFileSync9(input.audioPath);
7936
7934
  const layout = { sizeBytes: bytes.length, payloadStart, payloadEnd, partStarts: {} };
7937
- mkdirSync9(join20(input.planDir, SLICE_DIR), { recursive: true });
7935
+ mkdirSync9(join19(input.planDir, SLICE_DIR), { recursive: true });
7938
7936
  input.chunks.forEach((_, index) => {
7939
7937
  const cut = audioCutForChunk(grid, input.chunks, index, input.fps);
7940
7938
  if (!cut) return;
@@ -7942,17 +7940,17 @@ async function writeChunkAudioSlices(input) {
7942
7940
  const start = offsets[cut.firstPacket];
7943
7941
  layout.partStarts[index] = start;
7944
7942
  const end = offsets[last] + track.sizes[last];
7945
- writeFileSync5(join20(input.planDir, chunkAudioSlicePath(index)), bytes.subarray(start, end));
7943
+ writeFileSync5(join19(input.planDir, chunkAudioSlicePath(index)), bytes.subarray(start, end));
7946
7944
  });
7947
7945
  const frame = [bytes.subarray(0, payloadStart), bytes.subarray(payloadEnd)];
7948
- writeFileSync5(join20(input.planDir, SLICE_FRAME), Buffer.concat(frame));
7949
- 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));
7950
7948
  }
7951
7949
  function chunkAudioSource(planDir, chunkIndex, scratchPath) {
7952
- const layoutPath = join20(planDir, SLICE_LAYOUT);
7950
+ const layoutPath = join19(planDir, SLICE_LAYOUT);
7953
7951
  if (!existsSync14(layoutPath)) return resolvePlanAudioPath(planDir);
7954
7952
  const layout = JSON.parse(readFileSync9(layoutPath, "utf-8"));
7955
- const frame = readFileSync9(join20(planDir, SLICE_FRAME));
7953
+ const frame = readFileSync9(join19(planDir, SLICE_FRAME));
7956
7954
  const fd = openSync4(scratchPath, "w");
7957
7955
  try {
7958
7956
  writeSync(fd, frame, 0, layout.payloadStart, 0);
@@ -7965,7 +7963,7 @@ function chunkAudioSource(planDir, chunkIndex, scratchPath) {
7965
7963
  );
7966
7964
  const start = layout.partStarts[chunkIndex];
7967
7965
  if (start !== void 0) {
7968
- const part = readFileSync9(join20(planDir, chunkAudioSlicePath(chunkIndex)));
7966
+ const part = readFileSync9(join19(planDir, chunkAudioSlicePath(chunkIndex)));
7969
7967
  writeSync(fd, part, 0, part.length, start);
7970
7968
  }
7971
7969
  ftruncateSync(fd, layout.sizeBytes);
@@ -8021,7 +8019,7 @@ async function muxChunkWithAudio(input) {
8021
8019
  // src/services/distributed/planSize.ts
8022
8020
  import { createHash as createHash5 } from "node:crypto";
8023
8021
  import { lstatSync, readdirSync as readdirSync6 } from "node:fs";
8024
- 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";
8025
8023
  var MAX_TOP_COMPONENTS = 10;
8026
8024
  var SOURCE_MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
8027
8025
  ".aac",
@@ -8133,7 +8131,7 @@ function walkRegularFiles(dir, visit) {
8133
8131
  return;
8134
8132
  }
8135
8133
  for (const entry of entries) {
8136
- const full = join21(dir, entry.name);
8134
+ const full = join20(dir, entry.name);
8137
8135
  if (entry.isDirectory()) {
8138
8136
  walkRegularFiles(full, visit);
8139
8137
  } else if (entry.isFile()) {
@@ -8209,7 +8207,6 @@ function chunkSizingUsesFrameCost(config) {
8209
8207
  }
8210
8208
  var CHUNK_EXCEEDS_WORKER_TIMEOUT = "CHUNK_EXCEEDS_WORKER_TIMEOUT";
8211
8209
  var ChunkExceedsWorkerTimeoutError = class extends Error {
8212
- // fallow-ignore-next-line unused-class-member
8213
8210
  code = CHUNK_EXCEEDS_WORKER_TIMEOUT;
8214
8211
  constructor(predictedChunkSeconds, workerTimeoutSeconds) {
8215
8212
  super(
@@ -8272,7 +8269,6 @@ var PlanTooLargeError = class extends Error {
8272
8269
  };
8273
8270
  var FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED";
8274
8271
  var FormatNotSupportedInDistributedError = class extends Error {
8275
- // fallow-ignore-next-line unused-class-member
8276
8272
  code = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
8277
8273
  format;
8278
8274
  reason;
@@ -8347,13 +8343,13 @@ function assertPositiveInteger(name, value) {
8347
8343
  }
8348
8344
  var FREEZE_OWNED_PLAN_FILES = [
8349
8345
  "plan.json",
8350
- join22("meta", "composition.json"),
8351
- join22("meta", "encoder.json"),
8352
- join22("meta", "chunks.json")
8346
+ join21("meta", "composition.json"),
8347
+ join21("meta", "encoder.json"),
8348
+ join21("meta", "chunks.json")
8353
8349
  ];
8354
8350
  function removeFreezeOwnedPlanFiles(planDir) {
8355
8351
  for (const relativePath of FREEZE_OWNED_PLAN_FILES) {
8356
- rmSync8(join22(planDir, relativePath), { force: true });
8352
+ rmSync9(join21(planDir, relativePath), { force: true });
8357
8353
  }
8358
8354
  }
8359
8355
  function buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize) {
@@ -8412,6 +8408,7 @@ function buildLockedRenderConfig(input) {
8412
8408
  chunkCount: input.chunkCount,
8413
8409
  runtimeEnv: input.runtimeEnv,
8414
8410
  variables: config.variables,
8411
+ pageMotionBlur: config.pageMotionBlur,
8415
8412
  ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
8416
8413
  ...input.separateAudio ? { separateAudio: true } : {}
8417
8414
  };
@@ -8485,19 +8482,19 @@ function createVideoSourceShipper(input) {
8485
8482
  const known = shipped.get(srcPath);
8486
8483
  if (known) return known;
8487
8484
  const stagedRel = pathInside(input.workVideoFramesDir, srcPath);
8488
- const current = stagedRel === null ? srcPath : join22(input.planDir, "video-frames", stagedRel);
8485
+ const current = stagedRel === null ? srcPath : join21(input.planDir, "video-frames", stagedRel);
8489
8486
  let result = null;
8490
8487
  for (const root of stagedRel === null ? [input.workCompiledDir, input.projectDir] : []) {
8491
8488
  const rel = pathInside(root, srcPath);
8492
- 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))) {
8493
8490
  result = ["compiled", ...rel.split(sep2)].join("/");
8494
8491
  break;
8495
8492
  }
8496
8493
  }
8497
8494
  if (result === null) {
8498
8495
  result = `video-sources/${shipped.size}${extname4(current)}`;
8499
- const destination = join22(input.planDir, ...result.split("/"));
8500
- 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 });
8501
8498
  try {
8502
8499
  linkSync(current, destination);
8503
8500
  } catch {
@@ -8509,7 +8506,7 @@ function createVideoSourceShipper(input) {
8509
8506
  };
8510
8507
  }
8511
8508
  function plansSeparateAudio(config, options) {
8512
- return options.deferAudio === true && config.format === "mp4" && config.cfr !== true;
8509
+ return options.deferAudio === true && config.format !== "png-sequence" && config.cfr !== true;
8513
8510
  }
8514
8511
  async function compilePlanComposition(projectDir, config, planDir, options) {
8515
8512
  rejectUnsupportedDistributedFormat(config);
@@ -8546,14 +8543,14 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8546
8543
  variables: config.variables
8547
8544
  });
8548
8545
  const entryFile = config.entryFile ?? "index.html";
8549
- const htmlPath = join22(projectDir, entryFile);
8546
+ const htmlPath = join21(projectDir, entryFile);
8550
8547
  if (!existsSync15(htmlPath)) {
8551
8548
  throw new Error(`[plan] entry file not found: ${htmlPath}`);
8552
8549
  }
8553
- const workDir = join22(planDir, ".plan-work");
8554
- rmSync8(workDir, { recursive: true, force: true });
8550
+ const workDir = join21(planDir, ".plan-work");
8551
+ rmSync9(workDir, { recursive: true, force: true });
8555
8552
  mkdirSync10(workDir, { recursive: true });
8556
- const compiledDir = join22(workDir, "compiled");
8553
+ const compiledDir = join21(workDir, "compiled");
8557
8554
  mkdirSync10(compiledDir, { recursive: true });
8558
8555
  let stageStarted = Date.now();
8559
8556
  cpSync2(projectDir, compiledDir, {
@@ -8567,10 +8564,10 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8567
8564
  }
8568
8565
  });
8569
8566
  logPlanStage(log, "copy", stageStarted);
8570
- const finalCompiledDir = join22(planDir, "compiled");
8567
+ const finalCompiledDir = join21(planDir, "compiled");
8571
8568
  const needsAlpha = outputNeedsAlpha(config.format);
8572
8569
  const remoteFiles = Object.entries(options.remoteFiles ?? {});
8573
- const remoteLocalPath = (rel) => join22(projectDir, ...rel.split("/"));
8570
+ const remoteLocalPath = (rel) => join21(projectDir, ...rel.split("/"));
8574
8571
  const urlByAbsentLocalPath = new Map(
8575
8572
  remoteFiles.map(([rel, file]) => [remoteLocalPath(rel), file.url])
8576
8573
  );
@@ -8596,7 +8593,8 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8596
8593
  // Distributed renders must not capture host-specific system fonts —
8597
8594
  // the Lambda/worker filesystem won't have the same fonts installed.
8598
8595
  allowSystemFontCapture: false,
8599
- variables: config.variables
8596
+ variables: config.variables,
8597
+ pageMotionBlur: job.config.pageMotionBlur
8600
8598
  });
8601
8599
  logPlanStage(log, "compile", stageStarted);
8602
8600
  let compiled = compileResult.compiled;
@@ -8668,22 +8666,21 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
8668
8666
  forceScreenshot,
8669
8667
  width,
8670
8668
  height,
8671
- probeResult,
8672
8669
  totalFrames
8673
8670
  };
8674
8671
  }
8675
- function mixPlanAudio(c, compiledDir, seconds, pcmPath) {
8672
+ function mixPlanAudio(c, compiledDir) {
8676
8673
  return runAudioStage({
8677
8674
  projectDir: c.projectDir,
8678
8675
  workDir: c.workDir,
8679
8676
  compiledDir,
8680
8677
  urlByAbsentLocalPath: c.urlByAbsentLocalPath,
8681
- duration: seconds,
8678
+ totalFrames: c.totalFrames,
8679
+ fps: c.job.config.fps,
8682
8680
  ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
8683
8681
  audioGain: c.cfg.audioGain,
8684
8682
  audios: c.composition.audios,
8685
8683
  masterFxChain: c.composition.masterFxChain,
8686
- ...pcmPath ? { keepEncoderInputAt: pcmPath } : {},
8687
8684
  abortSignal: c.abortSignal,
8688
8685
  assertNotAborted: c.assertNotAborted
8689
8686
  });
@@ -8707,16 +8704,6 @@ async function extractPlanVideos(c, deferRangeExtraction) {
8707
8704
  }
8708
8705
  return extractResult;
8709
8706
  }
8710
- async function holdPlanAudioPeak(c, audioPath, pcmPath, seconds) {
8711
- const peak = await constrainAacTruePeak({
8712
- audioPath,
8713
- ...existsSync15(pcmPath) ? { sourcePath: pcmPath } : {},
8714
- targetDurationSeconds: seconds,
8715
- signal: c.abortSignal,
8716
- timeoutMs: c.cfg.ffmpegProcessTimeout
8717
- }).finally(() => rmSync8(pcmPath, { force: true }));
8718
- if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
8719
- }
8720
8707
  async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
8721
8708
  const planDir = executionPlanDir;
8722
8709
  const c = await compilePlanComposition(projectDir, config, planDir, options);
@@ -8738,7 +8725,6 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8738
8725
  forceScreenshot,
8739
8726
  width,
8740
8727
  height,
8741
- probeResult,
8742
8728
  totalFrames
8743
8729
  } = c;
8744
8730
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
@@ -8754,17 +8740,17 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8754
8740
  let stageStarted = Date.now();
8755
8741
  const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
8756
8742
  logPlanStage(log, "extract", stageStarted);
8757
- const stagedVideoFrames = join22(compiledDir, "__hyperframes_video_frames");
8758
- const videoFramesDst = join22(planDir, "video-frames");
8759
- if (existsSync15(videoFramesDst)) rmSync8(videoFramesDst, { recursive: true, force: true });
8760
- const videoSourcesDir = join22(planDir, "video-sources");
8761
- 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 });
8762
8748
  if (existsSync15(stagedVideoFrames)) {
8763
8749
  renameSync4(stagedVideoFrames, videoFramesDst);
8764
8750
  } else {
8765
8751
  mkdirSync10(videoFramesDst, { recursive: true });
8766
8752
  }
8767
- if (existsSync15(finalCompiledDir)) rmSync8(finalCompiledDir, { recursive: true, force: true });
8753
+ if (existsSync15(finalCompiledDir)) rmSync9(finalCompiledDir, { recursive: true, force: true });
8768
8754
  renameSync4(compiledDir, finalCompiledDir);
8769
8755
  const shipVideoSource = createVideoSourceShipper({
8770
8756
  planDir,
@@ -8794,7 +8780,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8794
8780
  };
8795
8781
  })
8796
8782
  });
8797
- mkdirSync10(join22(planDir, "meta"), { recursive: true });
8783
+ mkdirSync10(join21(planDir, "meta"), { recursive: true });
8798
8784
  writePlanRemoteFiles(
8799
8785
  planDir,
8800
8786
  remoteFiles.map(([rel, { sha256, sizeBytes }]) => ({
@@ -8804,23 +8790,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8804
8790
  }))
8805
8791
  );
8806
8792
  writeFileSync6(
8807
- join22(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
8793
+ join21(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
8808
8794
  JSON.stringify(planVideosJson, null, 2),
8809
8795
  "utf-8"
8810
8796
  );
8811
8797
  const digests = options.digests ?? /* @__PURE__ */ new Map();
8812
8798
  const separateAudio = plansSeparateAudio(config, options);
8813
8799
  const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options);
8814
- const audioSeconds = chunksCarryAudio ? totalFrames / config.fps : probeResult.duration;
8815
- const planMixPcmPath = join22(workDir, "plan-mix.wav");
8816
8800
  stageStarted = Date.now();
8817
8801
  const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
8818
- separateAudio ? null : mixPlanAudio(
8819
- c,
8820
- finalCompiledDir,
8821
- audioSeconds,
8822
- chunksCarryAudio ? planMixPcmPath : void 0
8823
- ).finally(() => logPlanStage(log, "audio", stageStarted)),
8802
+ separateAudio ? null : mixPlanAudio(c, finalCompiledDir).finally(() => logPlanStage(log, "audio", stageStarted)),
8824
8803
  (async () => {
8825
8804
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
8826
8805
  await prehashPlanFiles(dir, digests);
@@ -8873,14 +8852,11 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8873
8852
  if (audioResult?.audioError) {
8874
8853
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
8875
8854
  }
8876
- const planAudioPath = join22(planDir, PLAN_AUDIO_RELATIVE_PATH);
8855
+ const planAudioPath = join21(planDir, PLAN_AUDIO_RELATIVE_PATH);
8877
8856
  if (audioResult?.hasAudio && existsSync15(audioResult.audioOutputPath)) {
8878
8857
  renameSync4(audioResult.audioOutputPath, planAudioPath);
8879
8858
  }
8880
8859
  if (chunksCarryAudio && existsSync15(planAudioPath)) {
8881
- stageStarted = Date.now();
8882
- await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
8883
- logPlanStage(log, "audio-peak", stageStarted);
8884
8860
  if (options.sliceChunkAudio) {
8885
8861
  stageStarted = Date.now();
8886
8862
  await writeChunkAudioSlices({
@@ -8925,7 +8901,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
8925
8901
  format: config.format
8926
8902
  };
8927
8903
  try {
8928
- rmSync8(workDir, { recursive: true, force: true });
8904
+ rmSync9(workDir, { recursive: true, force: true });
8929
8905
  } catch (err) {
8930
8906
  log.warn("[plan] failed to remove temp work dir", {
8931
8907
  workDir,
@@ -8982,25 +8958,20 @@ async function buildPlanAudio(projectDir, config, workRoot, outputPath, options
8982
8958
  executionPlanSizeLimitBytes: Number.MAX_SAFE_INTEGER
8983
8959
  });
8984
8960
  try {
8985
- const seconds = c.totalFrames / config.fps;
8986
- const pcmPath = join22(c.workDir, "plan-mix.wav");
8987
8961
  let stageStarted = Date.now();
8988
8962
  await extractPlanVideos(c, true);
8989
8963
  logPlanStage(c.log, "extract", stageStarted);
8990
8964
  stageStarted = Date.now();
8991
- const mixed = await mixPlanAudio(c, c.compiledDir, seconds, pcmPath);
8965
+ const mixed = await mixPlanAudio(c, c.compiledDir);
8992
8966
  logPlanStage(c.log, "audio", stageStarted);
8993
8967
  if (mixed.audioError) {
8994
8968
  applyDistributedAudioWarningPolicy(c.job, mixed.audioError, mixed.audioFailures, c.log);
8995
8969
  }
8996
8970
  if (!mixed.hasAudio || !existsSync15(mixed.audioOutputPath)) return { hasAudio: false };
8997
8971
  renameSync4(mixed.audioOutputPath, outputPath);
8998
- stageStarted = Date.now();
8999
- await holdPlanAudioPeak(c, outputPath, pcmPath, seconds);
9000
- logPlanStage(c.log, "audio-peak", stageStarted);
9001
8972
  return { hasAudio: true };
9002
8973
  } finally {
9003
- rmSync8(c.workDir, { recursive: true, force: true });
8974
+ rmSync9(c.workDir, { recursive: true, force: true });
9004
8975
  }
9005
8976
  }
9006
8977
  async function plan(projectDir, config, planDir, options = {}) {
@@ -9030,12 +9001,12 @@ import {
9030
9001
  readFileSync as readFileSync10,
9031
9002
  readdirSync as readdirSync7,
9032
9003
  renameSync as renameSync6,
9033
- rmSync as rmSync10,
9004
+ rmSync as rmSync11,
9034
9005
  statSync as statSync6,
9035
9006
  writeFileSync as writeFileSync8
9036
9007
  } from "node:fs";
9037
9008
  import { tmpdir } from "node:os";
9038
- 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";
9039
9010
  import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
9040
9011
  import { createFrameLookupTable as createFrameLookupTable4 } from "@hyperframes/engine";
9041
9012
 
@@ -9047,11 +9018,11 @@ import {
9047
9018
  mkdirSync as mkdirSync11,
9048
9019
  mkdtempSync as mkdtempSync4,
9049
9020
  renameSync as renameSync5,
9050
- rmSync as rmSync9,
9021
+ rmSync as rmSync10,
9051
9022
  statSync as statSync5,
9052
9023
  writeFileSync as writeFileSync7
9053
9024
  } from "node:fs";
9054
- import { dirname as dirname10, join as join24 } from "node:path";
9025
+ import { dirname as dirname10, join as join23 } from "node:path";
9055
9026
 
9056
9027
  // src/services/distributed/planV2Errors.ts
9057
9028
  var PLAN_V2_INTEGRITY_UNRECOVERABLE = "PLAN_V2_INTEGRITY_UNRECOVERABLE";
@@ -9066,7 +9037,7 @@ var PlanV2IntegrityError = class extends Error {
9066
9037
  };
9067
9038
 
9068
9039
  // src/services/distributed/planV2Layout.ts
9069
- import { join as join23 } from "node:path";
9040
+ import { join as join22 } from "node:path";
9070
9041
  var SHA256_PATTERN = /^[0-9a-f]{64}$/;
9071
9042
  function assertPlanV2Sha256(value, field) {
9072
9043
  if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
@@ -9076,7 +9047,7 @@ function assertPlanV2Sha256(value, field) {
9076
9047
  }
9077
9048
  function planV2BlobPath(root, sha256) {
9078
9049
  const digest = assertPlanV2Sha256(sha256, "artifact sha256");
9079
- return join23(root, "artifacts", "sha256", digest.slice(0, 2), digest);
9050
+ return join22(root, "artifacts", "sha256", digest.slice(0, 2), digest);
9080
9051
  }
9081
9052
 
9082
9053
  // src/services/distributed/planV2Publisher.ts
@@ -9117,7 +9088,7 @@ var LocalPlanV2ArtifactPublisher = class {
9117
9088
  this.destinationDir = destinationDir;
9118
9089
  this.#linkFile = options.linkFile ?? linkSync2;
9119
9090
  mkdirSync11(dirname10(destinationDir), { recursive: true });
9120
- this.temporaryDir = mkdtempSync4(join24(dirname10(destinationDir), ".plan-v2-publish-"));
9091
+ this.temporaryDir = mkdtempSync4(join23(dirname10(destinationDir), ".plan-v2-publish-"));
9121
9092
  }
9122
9093
  async putBlob(blob) {
9123
9094
  const digest = assertPlanV2Sha256(blob.sha256, "published blob sha256");
@@ -9130,8 +9101,8 @@ var LocalPlanV2ArtifactPublisher = class {
9130
9101
  const destinationPath = planV2BlobPath(this.temporaryDir, digest);
9131
9102
  if (existsSync16(destinationPath)) return;
9132
9103
  mkdirSync11(dirname10(destinationPath), { recursive: true });
9133
- const stagingDir = mkdtempSync4(join24(dirname10(destinationPath), ".plan-v2-blob-"));
9134
- const temporaryPath = join24(stagingDir, "blob");
9104
+ const stagingDir = mkdtempSync4(join23(dirname10(destinationPath), ".plan-v2-blob-"));
9105
+ const temporaryPath = join23(stagingDir, "blob");
9135
9106
  try {
9136
9107
  try {
9137
9108
  this.#linkFile(blob.sourcePath, temporaryPath);
@@ -9141,7 +9112,7 @@ var LocalPlanV2ArtifactPublisher = class {
9141
9112
  }
9142
9113
  renameSync5(temporaryPath, destinationPath);
9143
9114
  } finally {
9144
- rmSync9(stagingDir, { recursive: true, force: true });
9115
+ rmSync10(stagingDir, { recursive: true, force: true });
9145
9116
  }
9146
9117
  }
9147
9118
  async commitManifest(manifestBytes) {
@@ -9152,13 +9123,13 @@ var LocalPlanV2ArtifactPublisher = class {
9152
9123
  );
9153
9124
  }
9154
9125
  }
9155
- writeFileSync7(join24(this.temporaryDir, "plan.json"), manifestBytes, "utf-8");
9126
+ writeFileSync7(join23(this.temporaryDir, "plan.json"), manifestBytes, "utf-8");
9156
9127
  renameSync5(this.temporaryDir, this.destinationDir);
9157
9128
  this.#committed = true;
9158
9129
  }
9159
9130
  async abort() {
9160
9131
  if (!this.#committed) {
9161
- rmSync9(this.temporaryDir, { recursive: true, force: true });
9132
+ rmSync10(this.temporaryDir, { recursive: true, force: true });
9162
9133
  }
9163
9134
  }
9164
9135
  };
@@ -9197,7 +9168,7 @@ function listFiles(root) {
9197
9168
  const rootResolved = resolve8(root);
9198
9169
  function walk(dir) {
9199
9170
  for (const entry of readdirSync7(dir, { withFileTypes: true })) {
9200
- const absolutePath = join25(dir, entry.name);
9171
+ const absolutePath = join24(dir, entry.name);
9201
9172
  if (entry.isDirectory()) {
9202
9173
  walk(absolutePath);
9203
9174
  } else if (entry.isFile()) {
@@ -9224,13 +9195,13 @@ function assertValidExtractionCacheCompleteSentinel(path) {
9224
9195
  }
9225
9196
  }
9226
9197
  function validateExtractionCacheCompleteSentinels(executionPlanDir) {
9227
- const videoRoot = join25(executionPlanDir, "video-frames");
9198
+ const videoRoot = join24(executionPlanDir, "video-frames");
9228
9199
  if (!existsSync17(videoRoot)) return;
9229
9200
  for (const videoEntry of readdirSync7(videoRoot, { withFileTypes: true })) {
9230
9201
  if (!videoEntry.isDirectory()) continue;
9231
- const videoDir = join25(videoRoot, videoEntry.name);
9202
+ const videoDir = join24(videoRoot, videoEntry.name);
9232
9203
  if (readdirSync7(videoDir).includes(EXTRACTION_CACHE_COMPLETE_SENTINEL)) {
9233
- const sentinelPath = join25(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL);
9204
+ const sentinelPath = join24(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL);
9234
9205
  assertValidExtractionCacheCompleteSentinel(sentinelPath);
9235
9206
  }
9236
9207
  }
@@ -9277,7 +9248,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
9277
9248
  if (video.deferredRange) {
9278
9249
  const framePaths2 = /* @__PURE__ */ new Map();
9279
9250
  for (let index = 0; index < video.totalFrames; index++) {
9280
- framePaths2.set(index, join25(outputDir, String(index)));
9251
+ framePaths2.set(index, join24(outputDir, String(index)));
9281
9252
  }
9282
9253
  return { ...video, outputDir, framePaths: framePaths2, ownedByLookup: false };
9283
9254
  }
@@ -9285,7 +9256,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
9285
9256
  const framePaths = /* @__PURE__ */ new Map();
9286
9257
  for (const frameName of frameNames) {
9287
9258
  if (frameName === EXTRACTION_CACHE_COMPLETE_SENTINEL) {
9288
- assertValidExtractionCacheCompleteSentinel(join25(outputDir, frameName));
9259
+ assertValidExtractionCacheCompleteSentinel(join24(outputDir, frameName));
9289
9260
  continue;
9290
9261
  }
9291
9262
  const match = /(\d+)(?=\.[^.]+$)/.exec(frameName);
@@ -9304,7 +9275,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
9304
9275
  `duplicate extracted frame index ${frameIndex} in ${outputDir}`
9305
9276
  );
9306
9277
  }
9307
- framePaths.set(frameIndex, join25(outputDir, frameName));
9278
+ framePaths.set(frameIndex, join24(outputDir, frameName));
9308
9279
  }
9309
9280
  return {
9310
9281
  ...video,
@@ -9325,7 +9296,7 @@ function parsePlanVideosJson2(value) {
9325
9296
  }
9326
9297
  }
9327
9298
  function materializeExtractedVideoDirectories(planDir) {
9328
- const videosPath = join25(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9299
+ const videosPath = join24(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9329
9300
  if (!existsSync17(videosPath)) return;
9330
9301
  const videos = parsePlanVideosJson2(readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH));
9331
9302
  for (const video of videos.extracted) {
@@ -9354,13 +9325,13 @@ function parseChunkSlices(value) {
9354
9325
  });
9355
9326
  }
9356
9327
  function buildVideoChunkDependencies(executionPlanDir, dimensions) {
9357
- const videoRoot = join25(executionPlanDir, "video-frames");
9328
+ const videoRoot = join24(executionPlanDir, "video-frames");
9358
9329
  const hasExtractedFrames = existsSync17(videoRoot) && listFiles(videoRoot).length > 0;
9359
- const videosPath = join25(executionPlanDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9330
+ const videosPath = join24(executionPlanDir, PLAN_VIDEOS_META_RELATIVE_PATH);
9360
9331
  if (!existsSync17(videosPath)) {
9361
9332
  return hasExtractedFrames ? { mode: "full-source-pack", dependencies: null } : { mode: "exact-rendered-frames", dependencies: /* @__PURE__ */ new Map() };
9362
9333
  }
9363
- const chunksPath = join25(executionPlanDir, "meta", "chunks.json");
9334
+ const chunksPath = join24(executionPlanDir, "meta", "chunks.json");
9364
9335
  const videos = readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH);
9365
9336
  const chunks = readJsonFile(chunksPath, "meta/chunks.json");
9366
9337
  const parsedVideos = parsePlanVideosJson2(videos);
@@ -9416,13 +9387,13 @@ function computeManifestHash(manifest) {
9416
9387
  function writeBlob(sourcePath, destinationPath) {
9417
9388
  if (existsSync17(destinationPath)) return;
9418
9389
  mkdirSync12(dirname11(destinationPath), { recursive: true });
9419
- const temporaryDir = mkdtempSync5(join25(dirname11(destinationPath), ".plan-v2-blob-"));
9420
- const temporaryPath = join25(temporaryDir, "blob");
9390
+ const temporaryDir = mkdtempSync5(join24(dirname11(destinationPath), ".plan-v2-blob-"));
9391
+ const temporaryPath = join24(temporaryDir, "blob");
9421
9392
  try {
9422
9393
  copyFileSync7(sourcePath, temporaryPath);
9423
9394
  renameSync6(temporaryPath, destinationPath);
9424
9395
  } finally {
9425
- rmSync10(temporaryDir, { recursive: true, force: true });
9396
+ rmSync11(temporaryDir, { recursive: true, force: true });
9426
9397
  }
9427
9398
  }
9428
9399
  function publishBlobFor(absolutePath, digests) {
@@ -9453,7 +9424,7 @@ async function putTasks(tasks) {
9453
9424
  if (failure) throw failure.reason;
9454
9425
  }
9455
9426
  function buildPlanV2Publication(executionPlanDir, digests) {
9456
- const executionPlanPath = join25(executionPlanDir, "plan.json");
9427
+ const executionPlanPath = join24(executionPlanDir, "plan.json");
9457
9428
  if (!existsSync17(executionPlanPath)) {
9458
9429
  throw new PlanV2IntegrityError(`execution plan is missing plan.json: ${executionPlanPath}`);
9459
9430
  }
@@ -9481,7 +9452,7 @@ function buildPlanV2Publication(executionPlanDir, digests) {
9481
9452
  }
9482
9453
  validateExtractionCacheCompleteSentinels(executionPlanDir);
9483
9454
  const videoDependencyPlan = buildVideoChunkDependencies(executionPlanDir, dimensions);
9484
- const encoder = readJsonFile(join25(executionPlanDir, "meta", "encoder.json"), "meta/encoder.json");
9455
+ const encoder = readJsonFile(join24(executionPlanDir, "meta", "encoder.json"), "meta/encoder.json");
9485
9456
  const files = listFiles(executionPlanDir);
9486
9457
  const chunkAudio = !isRecord3(encoder) || encoder.chunksCarryAudio !== true ? "none" : files.some((file) => chunkAudioSliceTarget(file.path) !== null) ? "sliced" : "whole";
9487
9458
  for (const file of files) {
@@ -9542,20 +9513,20 @@ function createPlanV2FromExecutionPlan(executionPlanDir, planV2Dir) {
9542
9513
  throw new PlanV2IntegrityError("a local plan v2 directory cannot reference remote files");
9543
9514
  }
9544
9515
  mkdirSync12(dirname11(planV2Dir), { recursive: true });
9545
- const tempDir = mkdtempSync5(join25(dirname11(planV2Dir), ".plan-v2-build-"));
9516
+ const tempDir = mkdtempSync5(join24(dirname11(planV2Dir), ".plan-v2-build-"));
9546
9517
  try {
9547
9518
  for (const blob of publication.blobs) {
9548
9519
  writeBlob(blob.sourcePath, planV2BlobPath(tempDir, blob.sha256));
9549
9520
  }
9550
9521
  writeFileSync8(
9551
- join25(tempDir, "plan.json"),
9522
+ join24(tempDir, "plan.json"),
9552
9523
  canonicalJsonStringify(publication.manifest),
9553
9524
  "utf-8"
9554
9525
  );
9555
9526
  renameSync6(tempDir, planV2Dir);
9556
9527
  return resultFromManifest(planV2Dir, publication.manifest);
9557
9528
  } catch (error) {
9558
- rmSync10(tempDir, { recursive: true, force: true });
9529
+ rmSync11(tempDir, { recursive: true, force: true });
9559
9530
  throw error;
9560
9531
  }
9561
9532
  }
@@ -9595,7 +9566,7 @@ async function publishPlanV2FromV1(executionPlanDir, publisher) {
9595
9566
  return publishPlanV2FromExecutionPlan(executionPlanDir, publisher);
9596
9567
  }
9597
9568
  async function planV2WithPublisher(projectDir, config, publisher, options = {}) {
9598
- const stagingRoot = mkdtempSync5(join25(options.stagingParentDir ?? tmpdir(), ".execution-plan-"));
9569
+ const stagingRoot = mkdtempSync5(join24(options.stagingParentDir ?? tmpdir(), ".execution-plan-"));
9599
9570
  const digests = /* @__PURE__ */ new Map();
9600
9571
  try {
9601
9572
  await buildLocalExecutionPlan(projectDir, config, stagingRoot, {
@@ -9606,7 +9577,7 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9606
9577
  // artifactTargets ships every compiled/ file to all chunks, so each is in the manifest.
9607
9578
  whileAudioMixes: () => putBlobs(
9608
9579
  publisher,
9609
- listFiles(join25(stagingRoot, "compiled")).map(
9580
+ listFiles(join24(stagingRoot, "compiled")).map(
9610
9581
  (file) => publishBlobFor(file.absolutePath, digests)
9611
9582
  )
9612
9583
  ),
@@ -9622,7 +9593,7 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
9622
9593
  }
9623
9594
  throw error;
9624
9595
  } finally {
9625
- rmSync10(stagingRoot, { recursive: true, force: true });
9596
+ rmSync11(stagingRoot, { recursive: true, force: true });
9626
9597
  }
9627
9598
  }
9628
9599
  async function planV2(projectDir, config, planV2Dir) {
@@ -9638,7 +9609,7 @@ async function planV2(projectDir, config, planV2Dir) {
9638
9609
  function resultFromManifest(planV2Dir, manifest) {
9639
9610
  return {
9640
9611
  planDir: planV2Dir,
9641
- manifestPath: join25(planV2Dir, "plan.json"),
9612
+ manifestPath: join24(planV2Dir, "plan.json"),
9642
9613
  planProtocol: PLAN_PROTOCOL_V2,
9643
9614
  planHash: manifest.planHash,
9644
9615
  sourcePlanV1Hash: getPlanV2ExecutionPlanHash(manifest),
@@ -9774,7 +9745,7 @@ function parsePlanV2Manifest(value) {
9774
9745
  return parsed;
9775
9746
  }
9776
9747
  function readPlanV2Manifest(planV2Dir) {
9777
- const manifestPath = join25(planV2Dir, "plan.json");
9748
+ const manifestPath = join24(planV2Dir, "plan.json");
9778
9749
  if (!existsSync17(manifestPath)) {
9779
9750
  throw new PlanV2IntegrityError(`missing v2 manifest: ${manifestPath}`);
9780
9751
  }
@@ -9820,16 +9791,16 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
9820
9791
  sourcePath: verifyBlob(planV2Dir, artifact)
9821
9792
  }));
9822
9793
  mkdirSync12(dirname11(destinationDir), { recursive: true });
9823
- const tempDir = mkdtempSync5(join25(dirname11(destinationDir), ".plan-v2-materialize-"));
9794
+ const tempDir = mkdtempSync5(join24(dirname11(destinationDir), ".plan-v2-materialize-"));
9824
9795
  try {
9825
9796
  for (const { artifact, sourcePath } of verified) {
9826
- const destinationPath = join25(tempDir, ...artifact.path.split("/"));
9797
+ const destinationPath = join24(tempDir, ...artifact.path.split("/"));
9827
9798
  mkdirSync12(dirname11(destinationPath), { recursive: true });
9828
9799
  copyFileSync7(sourcePath, destinationPath);
9829
9800
  }
9830
9801
  if (target.role === "chunk") materializeExtractedVideoDirectories(tempDir);
9831
9802
  writeFileSync8(
9832
- join25(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
9803
+ join24(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
9833
9804
  canonicalJsonStringify({
9834
9805
  manifest,
9835
9806
  target,
@@ -9839,7 +9810,7 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
9839
9810
  );
9840
9811
  renameSync6(tempDir, destinationDir);
9841
9812
  } catch (error) {
9842
- rmSync10(tempDir, { recursive: true, force: true });
9813
+ rmSync11(tempDir, { recursive: true, force: true });
9843
9814
  throw error;
9844
9815
  }
9845
9816
  return {
@@ -9852,7 +9823,7 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
9852
9823
  // Join the artifact's OWN name, not the current constant: a plan written
9853
9824
  // before the container change carries the legacy name, and materializing it
9854
9825
  // under the new one would point at a file that was never written.
9855
- audioPath: target.role === "assembler" ? audioArtifact ? join25(destinationDir, audioArtifact.path) : null : null
9826
+ audioPath: target.role === "assembler" ? audioArtifact ? join24(destinationDir, audioArtifact.path) : null : null
9856
9827
  };
9857
9828
  }
9858
9829
  function assertRemoteSources(remoteSources, artifacts) {
@@ -9871,7 +9842,7 @@ function validatePlanV2MaterializedTarget(planDir, expectedTarget) {
9871
9842
  return readValidatedPlanV2Materialization(planDir, expectedTarget)?.manifest ?? null;
9872
9843
  }
9873
9844
  function readValidatedPlanV2Materialization(planDir, expectedTarget) {
9874
- const markerPath = join25(planDir, PLAN_V2_MATERIALIZATION_MARKER);
9845
+ const markerPath = join24(planDir, PLAN_V2_MATERIALIZATION_MARKER);
9875
9846
  if (!existsSync17(markerPath)) return null;
9876
9847
  const marker = readJsonFile(markerPath, "materialization marker");
9877
9848
  if (!isRecord3(marker) || !isRecord3(marker.manifest) || !isRecord3(marker.target)) {
@@ -9889,7 +9860,7 @@ function readValidatedPlanV2Materialization(planDir, expectedTarget) {
9889
9860
  assertRemoteSources(remoteSources, required);
9890
9861
  for (const artifact of required) {
9891
9862
  if (Object.hasOwn(remoteSources, artifact.path)) continue;
9892
- const path = join25(planDir, ...artifact.path.split("/"));
9863
+ const path = join24(planDir, ...artifact.path.split("/"));
9893
9864
  if (!existsSync17(path) || statSync6(path).size !== artifact.sizeBytes) {
9894
9865
  throw new PlanV2IntegrityError(
9895
9866
  `materialized artifact missing or truncated: ${artifact.path}`
@@ -9903,9 +9874,9 @@ function readValidatedPlanV2Materialization(planDir, expectedTarget) {
9903
9874
  }
9904
9875
 
9905
9876
  // src/services/distributed/planV2Execution.ts
9906
- import { mkdtempSync as mkdtempSync7, rmSync as rmSync13 } from "node:fs";
9877
+ import { mkdtempSync as mkdtempSync7, rmSync as rmSync14 } from "node:fs";
9907
9878
  import { tmpdir as tmpdir2 } from "node:os";
9908
- import { join as join28 } from "node:path";
9879
+ import { join as join27 } from "node:path";
9909
9880
 
9910
9881
  // src/services/distributed/assemble.ts
9911
9882
  import {
@@ -9916,11 +9887,11 @@ import {
9916
9887
  readFileSync as readFileSync11,
9917
9888
  readdirSync as readdirSync8,
9918
9889
  renameSync as renameSync7,
9919
- rmSync as rmSync11,
9890
+ rmSync as rmSync12,
9920
9891
  statSync as statSync7,
9921
9892
  writeFileSync as writeFileSync9
9922
9893
  } from "node:fs";
9923
- import { dirname as dirname12, join as join26 } from "node:path";
9894
+ import { dirname as dirname12, join as join25 } from "node:path";
9924
9895
  import {
9925
9896
  appendRenderProvenanceArgs as appendRenderProvenanceArgs2,
9926
9897
  describeFfmpegFailure as describeFfmpegFailure4,
@@ -9982,7 +9953,7 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
9982
9953
  };
9983
9954
  try {
9984
9955
  const videoSources = cfrEncoder ? [await reencodeCfr(ctx, chunkPaths, cfrEncoder)] : chunkPaths;
9985
- const finalPath = join26(workDir, `output.${format}`);
9956
+ const finalPath = join25(workDir, `output.${format}`);
9986
9957
  const audio = audioPath !== null && existsSync18(audioPath) ? audioPath : null;
9987
9958
  await writeJoined(ctx, videoSources, audio, finalPath);
9988
9959
  renameSync7(finalPath, outputPath);
@@ -9993,7 +9964,7 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
9993
9964
  });
9994
9965
  } finally {
9995
9966
  try {
9996
- rmSync11(workDir, { recursive: true, force: true });
9967
+ rmSync12(workDir, { recursive: true, force: true });
9997
9968
  } catch (err) {
9998
9969
  log.warn("[assemble] failed to remove work dir", {
9999
9970
  workDir,
@@ -10010,8 +9981,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
10010
9981
  };
10011
9982
  }
10012
9983
  function readAssemblePlan(planDir, chunkPaths) {
10013
- const planJsonPath = join26(planDir, "plan.json");
10014
- const chunksJsonPath = join26(planDir, "meta", "chunks.json");
9984
+ const planJsonPath = join25(planDir, "plan.json");
9985
+ const chunksJsonPath = join25(planDir, "meta", "chunks.json");
10015
9986
  if (!existsSync18(planJsonPath)) {
10016
9987
  throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
10017
9988
  }
@@ -10043,7 +10014,7 @@ function videoInputArgs(ctx, videoSources) {
10043
10014
  return videoSources.length === 1 ? ["-i", videoSources[0]] : concatDemuxerInput(videoSources, ctx.workDir, ctx.fpsArg);
10044
10015
  }
10045
10016
  async function reencodeCfr(ctx, chunkPaths, encoder) {
10046
- const cfrOutputPath = join26(ctx.workDir, `cfr.${ctx.format}`);
10017
+ const cfrOutputPath = join25(ctx.workDir, `cfr.${ctx.format}`);
10047
10018
  const cfrArgs = [
10048
10019
  ...videoInputArgs(ctx, chunkPaths),
10049
10020
  "-c:v",
@@ -10081,7 +10052,7 @@ async function writeJoined(ctx, videoSources, audioPath, finalPath) {
10081
10052
  );
10082
10053
  return;
10083
10054
  }
10084
- const paddedAudioPath = join26(ctx.workDir, "audio-padded.m4a");
10055
+ const paddedAudioPath = join25(ctx.workDir, "audio-padded.m4a");
10085
10056
  const padTrimResult = await ctx.timed(
10086
10057
  "audioMs",
10087
10058
  () => padOrTrimAudioToVideoFrameCount({
@@ -10121,7 +10092,7 @@ async function writeJoined(ctx, videoSources, audioPath, finalPath) {
10121
10092
  }
10122
10093
  }
10123
10094
  function concatDemuxerInput(chunkPaths, workDir, fpsArg) {
10124
- const listPath = join26(workDir, "concat-list.txt");
10095
+ const listPath = join25(workDir, "concat-list.txt");
10125
10096
  const body = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
10126
10097
  writeFileSync9(listPath, `${body}
10127
10098
  `, "utf-8");
@@ -10149,6 +10120,7 @@ async function runAssembleFfmpeg(args, step, signal, timeout) {
10149
10120
  function chunksAreStitchedMp4(planDir, cfr) {
10150
10121
  const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
10151
10122
  if (!chunksCarryAudio && !separateAudio) return false;
10123
+ if (planFormat(planDir) !== "mp4") return false;
10152
10124
  if (cfr) {
10153
10125
  throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks are stitched");
10154
10126
  }
@@ -10157,8 +10129,12 @@ function chunksAreStitchedMp4(planDir, cfr) {
10157
10129
  function stitchesSeparateAudio(planDir) {
10158
10130
  return lockedAudio(planDir).separateAudio === true;
10159
10131
  }
10132
+ function planFormat(planDir) {
10133
+ const plan2 = JSON.parse(readFileSync11(join25(planDir, "plan.json"), "utf-8"));
10134
+ return plan2.dimensions.format;
10135
+ }
10160
10136
  function lockedAudio(planDir) {
10161
- const encoderJsonPath = join26(planDir, "meta", "encoder.json");
10137
+ const encoderJsonPath = join25(planDir, "meta", "encoder.json");
10162
10138
  if (!existsSync18(encoderJsonPath)) return {};
10163
10139
  return JSON.parse(readFileSync11(encoderJsonPath, "utf-8"));
10164
10140
  }
@@ -10168,7 +10144,7 @@ function readCfrEncoder(planDir, format) {
10168
10144
  `[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.`
10169
10145
  );
10170
10146
  }
10171
- const encoderJsonPath = join26(planDir, "meta", "encoder.json");
10147
+ const encoderJsonPath = join25(planDir, "meta", "encoder.json");
10172
10148
  if (!existsSync18(encoderJsonPath)) {
10173
10149
  throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`);
10174
10150
  }
@@ -10181,7 +10157,7 @@ function readCfrEncoder(planDir, format) {
10181
10157
  return encoderJson;
10182
10158
  }
10183
10159
  function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, startTimeMs) {
10184
- if (existsSync18(outputPath)) rmSync11(outputPath, { recursive: true, force: true });
10160
+ if (existsSync18(outputPath)) rmSync12(outputPath, { recursive: true, force: true });
10185
10161
  mkdirSync13(outputPath, { recursive: true });
10186
10162
  let globalIdx = 0;
10187
10163
  for (const chunkDir of chunkPaths) {
@@ -10195,8 +10171,8 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
10195
10171
  throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`);
10196
10172
  }
10197
10173
  for (const frame of frames) {
10198
- const dst = join26(outputPath, formatExportFrameName(globalIdx, "png"));
10199
- cpSync3(join26(chunkDir, frame), dst);
10174
+ const dst = join25(outputPath, formatExportFrameName(globalIdx, "png"));
10175
+ cpSync3(join25(chunkDir, frame), dst);
10200
10176
  globalIdx += 1;
10201
10177
  }
10202
10178
  }
@@ -10206,13 +10182,13 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
10206
10182
  );
10207
10183
  }
10208
10184
  if (audioPath !== null && existsSync18(audioPath)) {
10209
- const sidecar = join26(outputPath, MIXED_AUDIO_FILENAME4);
10185
+ const sidecar = join25(outputPath, MIXED_AUDIO_FILENAME4);
10210
10186
  cpSync3(audioPath, sidecar);
10211
10187
  }
10212
10188
  let fileSize = 0;
10213
10189
  for (const name of readdirSync8(outputPath)) {
10214
10190
  try {
10215
- fileSize += statSync7(join26(outputPath, name)).size;
10191
+ fileSize += statSync7(join25(outputPath, name)).size;
10216
10192
  } catch {
10217
10193
  }
10218
10194
  }
@@ -10226,8 +10202,8 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
10226
10202
 
10227
10203
  // src/services/distributed/renderChunk.ts
10228
10204
  import { randomBytes } from "node:crypto";
10229
- import { existsSync as existsSync19, mkdirSync as mkdirSync14, readFileSync as readFileSync12, readdirSync as readdirSync9, rmSync as rmSync12, writeFileSync as writeFileSync10 } from "node:fs";
10230
- import { join as join27 } from "node:path";
10205
+ import { existsSync as existsSync19, mkdirSync as mkdirSync14, readFileSync as readFileSync12, readdirSync as readdirSync9, rmSync as rmSync13, writeFileSync as writeFileSync10 } from "node:fs";
10206
+ import { join as join26 } from "node:path";
10231
10207
  import {
10232
10208
  assertSwiftShader,
10233
10209
  BROWSER_GPU_NOT_SOFTWARE as BROWSER_GPU_NOT_SOFTWARE4,
@@ -10329,7 +10305,7 @@ function hashChunkOutput(outputPath, kind) {
10329
10305
  if (kind === "file") return sha256Hex(readFileSync12(outputPath));
10330
10306
  const entries = readdirSync9(outputPath).filter((name) => /\.(png|jpg|jpeg)$/i.test(name)).sort();
10331
10307
  const lines = entries.map(
10332
- (name) => `${name}\0${sha256Hex(readFileSync12(join27(outputPath, name)))}`
10308
+ (name) => `${name}\0${sha256Hex(readFileSync12(join26(outputPath, name)))}`
10333
10309
  );
10334
10310
  return sha256Hex(lines.join("\0"));
10335
10311
  }
@@ -10346,9 +10322,9 @@ function resolveLockedVp9CpuUsed(lockedEncoder) {
10346
10322
  async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10347
10323
  const start = Date.now();
10348
10324
  const log = defaultLogger;
10349
- const planJsonPath = join27(planDir, "plan.json");
10350
- const encoderJsonPath = join27(planDir, "meta", "encoder.json");
10351
- const chunksJsonPath = join27(planDir, "meta", "chunks.json");
10325
+ const planJsonPath = join26(planDir, "plan.json");
10326
+ const encoderJsonPath = join26(planDir, "meta", "encoder.json");
10327
+ const chunksJsonPath = join26(planDir, "meta", "chunks.json");
10352
10328
  if (!existsSync19(planJsonPath)) {
10353
10329
  throw new RenderChunkValidationError(
10354
10330
  MISSING_PLAN_ARTIFACT,
@@ -10374,7 +10350,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10374
10350
  }
10375
10351
  const encoder = JSON.parse(readFileSync12(encoderJsonPath, "utf-8"));
10376
10352
  const chunks = JSON.parse(readFileSync12(chunksJsonPath, "utf-8"));
10377
- const videosJsonPath = join27(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
10353
+ const videosJsonPath = join26(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
10378
10354
  let planVideos = null;
10379
10355
  if (existsSync19(videosJsonPath)) {
10380
10356
  try {
@@ -10406,7 +10382,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10406
10382
  }
10407
10383
  const range = resolveChunkFrameRange(slice, chunkIndex, options?.frameRange);
10408
10384
  const framesInChunk = range.endFrame - range.startFrame;
10409
- const compiledDir = join27(planDir, "compiled");
10385
+ const compiledDir = join26(planDir, "compiled");
10410
10386
  if (!existsSync19(compiledDir)) {
10411
10387
  throw new RenderChunkValidationError(
10412
10388
  MISSING_PLAN_ARTIFACT,
@@ -10464,7 +10440,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10464
10440
  };
10465
10441
  const workDir = `${outputChunkPath}.work.${process.pid}.${randomBytes(4).toString("hex")}`;
10466
10442
  mkdirSync14(workDir, { recursive: true });
10467
- const framesDir = join27(workDir, "captured-frames");
10443
+ const framesDir = join26(workDir, "captured-frames");
10468
10444
  mkdirSync14(framesDir, { recursive: true });
10469
10445
  const videoFramesDir = `${workDir}.video-frames`;
10470
10446
  const chunkFrameNumbers = Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i);
@@ -10489,7 +10465,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10489
10465
  dimensions: plan2.dimensions,
10490
10466
  encoder,
10491
10467
  videoCount: planVideos?.videos.length ?? 0,
10492
- html: readFileSync12(join27(compiledDir, "index.html"), "utf-8"),
10468
+ html: readFileSync12(join26(compiledDir, "index.html"), "utf-8"),
10493
10469
  // The plan ships a deferred source only to chunks that show it; don't wait on the rest.
10494
10470
  skipReadinessVideoIds: deferredVideos.filter(
10495
10471
  (video) => !shownFrames.get(video.videoId)?.size || remoteVideoIds.has(video.videoId)
@@ -10665,7 +10641,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10665
10641
  return observedMode;
10666
10642
  },
10667
10643
  resetForScreenshotRetry: () => {
10668
- rmSync12(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10644
+ rmSync13(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10669
10645
  mkdirSync14(framesDir, { recursive: true });
10670
10646
  capturePerfs.length = 0;
10671
10647
  captureAttempts.length = 0;
@@ -10703,7 +10679,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10703
10679
  if (isPngSequence) {
10704
10680
  if (!existsSync19(outputChunkPath)) mkdirSync14(outputChunkPath, { recursive: true });
10705
10681
  } else {
10706
- const outDir = join27(outputChunkPath, "..");
10682
+ const outDir = join26(outputChunkPath, "..");
10707
10683
  if (!existsSync19(outDir)) mkdirSync14(outDir, { recursive: true });
10708
10684
  }
10709
10685
  const encodeStarted = Date.now();
@@ -10758,8 +10734,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10758
10734
  fps: { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen },
10759
10735
  timeoutMs: cfg.ffmpegProcessTimeout
10760
10736
  });
10761
- rmSync12(videoOnlyPath, { force: true });
10762
- rmSync12(audioScratchPath, { force: true });
10737
+ rmSync13(videoOnlyPath, { force: true });
10738
+ rmSync13(audioScratchPath, { force: true });
10763
10739
  }
10764
10740
  encodeStageMs = Date.now() - encodeStarted;
10765
10741
  } finally {
@@ -10805,8 +10781,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10805
10781
  writeFileSync10(perfPath, `${JSON.stringify(perfPayload, null, 2)}
10806
10782
  `, "utf-8");
10807
10783
  try {
10808
- rmSync12(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10809
- rmSync12(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10784
+ rmSync13(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10785
+ rmSync13(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
10810
10786
  } catch (err) {
10811
10787
  log.warn("[renderChunk] failed to remove work dir", {
10812
10788
  workDir,
@@ -10835,18 +10811,18 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
10835
10811
 
10836
10812
  // src/services/distributed/planV2Execution.ts
10837
10813
  async function renderChunkV2(planV2Dir, chunkIndex, outputChunkPath, options) {
10838
- const workRoot = mkdtempSync7(join28(tmpdir2(), "hf-plan-v2-chunk-"));
10839
- const materializedPlanDir = join28(workRoot, "plan");
10814
+ const workRoot = mkdtempSync7(join27(tmpdir2(), "hf-plan-v2-chunk-"));
10815
+ const materializedPlanDir = join27(workRoot, "plan");
10840
10816
  try {
10841
10817
  materializePlanV2Target(planV2Dir, { role: "chunk", chunkIndex }, materializedPlanDir);
10842
10818
  return await renderChunk(materializedPlanDir, chunkIndex, outputChunkPath, options);
10843
10819
  } finally {
10844
- rmSync13(workRoot, { recursive: true, force: true });
10820
+ rmSync14(workRoot, { recursive: true, force: true });
10845
10821
  }
10846
10822
  }
10847
10823
  async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
10848
- const workRoot = mkdtempSync7(join28(tmpdir2(), "hf-plan-v2-assembler-"));
10849
- const materializedPlanDir = join28(workRoot, "plan");
10824
+ const workRoot = mkdtempSync7(join27(tmpdir2(), "hf-plan-v2-assembler-"));
10825
+ const materializedPlanDir = join27(workRoot, "plan");
10850
10826
  try {
10851
10827
  const materialized = materializePlanV2Target(
10852
10828
  planV2Dir,
@@ -10861,7 +10837,7 @@ async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
10861
10837
  options
10862
10838
  );
10863
10839
  } finally {
10864
- rmSync13(workRoot, { recursive: true, force: true });
10840
+ rmSync14(workRoot, { recursive: true, force: true });
10865
10841
  }
10866
10842
  }
10867
10843
 
@@ -10908,6 +10884,9 @@ function validateDistributedRenderConfig(config) {
10908
10884
  }
10909
10885
  validateIntDimension("config.width", config.width);
10910
10886
  validateIntDimension("config.height", config.height);
10887
+ if (config.pageMotionBlur !== void 0 && config.pageMotionBlur !== "off") {
10888
+ throw new InvalidConfigError("config.pageMotionBlur", 'must be "off" when provided');
10889
+ }
10911
10890
  if (!ALLOWED_FORMATS.includes(config.format)) {
10912
10891
  throw new InvalidConfigError(
10913
10892
  "config.format",
@@ -11110,7 +11089,7 @@ function validateIntDimension(field, value) {
11110
11089
  // src/services/distributed/projectHash.ts
11111
11090
  import { closeSync as closeSync5, openSync as openSync5, readdirSync as readdirSync10, readSync as readSync3, realpathSync as realpathSync2, statSync as statSync8 } from "node:fs";
11112
11091
  import { createHash as createHash6 } from "node:crypto";
11113
- import { join as join29 } from "node:path";
11092
+ import { join as join28 } from "node:path";
11114
11093
  var SITE_ID_SCHEME = "site-v2\0";
11115
11094
  var READ_CHUNK_BYTES = 256 * 1024;
11116
11095
  function listSiteFiles(projectDir) {
@@ -11119,7 +11098,7 @@ function listSiteFiles(projectDir) {
11119
11098
  function walk(dir, rel) {
11120
11099
  for (const name of readdirSync10(dir).sort()) {
11121
11100
  if (rel === "" && PLAN_PROJECT_DIR_SKIP_SEGMENTS.has(name)) continue;
11122
- const full = join29(dir, name);
11101
+ const full = join28(dir, name);
11123
11102
  const relPath = rel === "" ? name : `${rel}/${name}`;
11124
11103
  let stat;
11125
11104
  try {
@@ -11149,7 +11128,7 @@ function hashProjectDir(projectDir, files = listSiteFiles(projectDir)) {
11149
11128
  for (const rel of files) {
11150
11129
  hash.update(rel);
11151
11130
  hash.update("\0");
11152
- const fd = openSync5(join29(projectDir, rel), "r");
11131
+ const fd = openSync5(join28(projectDir, rel), "r");
11153
11132
  try {
11154
11133
  let read;
11155
11134
  while ((read = readSync3(fd, chunk, 0, chunk.length, null)) > 0) {