@hyperframes/producer 0.8.186 → 0.8.189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  mkdirSync as mkdirSync9,
6
6
  readFileSync as readFileSync8,
7
7
  readdirSync as readdirSync5,
8
- rmSync as rmSync9,
8
+ rmSync as rmSync10,
9
9
  statSync as statSync6,
10
10
  writeFileSync as writeFileSync5,
11
11
  copyFileSync as copyFileSync4,
@@ -55,7 +55,7 @@ import {
55
55
  augmentProtocolTimeoutError,
56
56
  augmentPageNavigationTimeoutError
57
57
  } from "@hyperframes/engine";
58
- import { join as join26, dirname as dirname10, resolve as resolve7 } from "path";
58
+ import { join as join25, dirname as dirname10, resolve as resolve7 } from "path";
59
59
  import { totalmem as totalmem2 } from "node:os";
60
60
  import { randomUUID } from "crypto";
61
61
  import { fileURLToPath as fileURLToPath4 } from "url";
@@ -4336,6 +4336,7 @@ function computeSegmentPlanHash(input) {
4336
4336
  input.useGpu ? "gpu" : "cpu",
4337
4337
  `${input.outputWidth}x${input.outputHeight}`,
4338
4338
  input.motionBlur,
4339
+ input.pageMotionBlur ?? "",
4339
4340
  input.captureQuality === void 0 ? "" : String(input.captureQuality)
4340
4341
  ].join(" ");
4341
4342
  return createHash2("sha256").update(ordered).digest("hex").slice(0, 16);
@@ -7193,7 +7194,9 @@ async function discoverMediaFromBrowser(page) {
7193
7194
  const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
7194
7195
  const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
7195
7196
  if (!id) return;
7196
- const src = htmlEl.currentSrc || htmlEl.src || htmlEl.getAttribute("src") || "";
7197
+ const ownSrc = htmlEl.getAttribute("src");
7198
+ const blankSrc = ownSrc !== null && ownSrc.trim() === "" && !htmlEl.querySelector("source[src]");
7199
+ const src = blankSrc ? "" : htmlEl.currentSrc || htmlEl.src || ownSrc || "";
7197
7200
  const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
7198
7201
  const endRaw = htmlEl.getAttribute("data-end");
7199
7202
  const durationRaw = htmlEl.getAttribute("data-duration");
@@ -8696,771 +8699,1209 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
8696
8699
  }
8697
8700
 
8698
8701
  // src/services/render/stages/audioStage.ts
8699
- import { join as join17 } from "node:path";
8702
+ import { join as join18 } from "node:path";
8703
+ import { rmSync as rmSync8 } from "node:fs";
8700
8704
  import {
8701
8705
  MIXED_AUDIO_FILENAME,
8702
8706
  audioFailureDiagnostics,
8703
8707
  processCompositionAudio
8704
8708
  } from "@hyperframes/engine";
8705
- function triggerFirst(failures, trigger) {
8706
- return [trigger, ...(failures ?? []).filter((failure) => failure !== trigger)];
8709
+
8710
+ // src/services/render/audioPadTrim.ts
8711
+ import { spawn } from "node:child_process";
8712
+ import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync7 } from "node:fs";
8713
+ import { dirname as dirname7, join as join17 } from "node:path";
8714
+ import {
8715
+ DEFAULT_CONFIG,
8716
+ describeFfmpegFailure as describeFfmpegFailure2,
8717
+ encodeAac,
8718
+ extractAudioMetadata as extractAudioMetadata3,
8719
+ getFfprobeBinary,
8720
+ ManagedChildProcess,
8721
+ runFfmpeg as runFfmpeg4,
8722
+ trackChildProcess
8723
+ } from "@hyperframes/engine";
8724
+ import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
8725
+
8726
+ // src/services/render/audioTruePeak.ts
8727
+ import {
8728
+ aacSegmentCount,
8729
+ describeFfmpegFailure,
8730
+ extractAudioMetadata as extractAudioMetadata2,
8731
+ runFfmpeg as runFfmpeg3
8732
+ } from "@hyperframes/engine";
8733
+ var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
8734
+ function checkedDbfs(value) {
8735
+ if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
8736
+ throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
8737
+ }
8738
+ return value;
8707
8739
  }
8708
- async function runAudioStage(input) {
8709
- const {
8710
- projectDir,
8711
- workDir,
8712
- compiledDir,
8713
- duration,
8714
- ffmpegProcessTimeout,
8715
- audioGain,
8716
- audios,
8717
- masterFxChain,
8718
- urlByAbsentLocalPath,
8719
- keepEncoderInputAt,
8720
- abortSignal,
8721
- assertNotAborted,
8722
- log
8723
- } = input;
8724
- const stage3Start = Date.now();
8725
- const audioOutputPath = join17(workDir, MIXED_AUDIO_FILENAME);
8726
- let hasAudio = false;
8727
- let audioError;
8728
- let audioFailures;
8729
- if (audios.length > 0) {
8730
- let audioResult;
8731
- try {
8732
- audioResult = await processCompositionAudio(
8733
- audios,
8734
- projectDir,
8735
- join17(workDir, "audio-work"),
8736
- audioOutputPath,
8737
- duration,
8738
- abortSignal,
8739
- {
8740
- ffmpegProcessTimeout,
8741
- audioGain,
8742
- masterFxChain,
8743
- urlByAbsentLocalPath,
8744
- keepEncoderInputAt
8745
- },
8746
- compiledDir
8747
- );
8748
- } catch (err) {
8749
- assertNotAborted();
8750
- const detail = err instanceof Error ? err.message : String(err);
8751
- return {
8752
- audioOutputPath,
8753
- hasAudio: false,
8754
- audioProcessMs: Date.now() - stage3Start,
8755
- audioError: detail,
8756
- // Synthesised rather than left undefined. The warning policy reads
8757
- // owner, retryability, reason and stage off this list, so reporting the
8758
- // FATAL failure — the one that took the whole mix down — with an empty
8759
- // one gave it strictly less classification than a single dropped track
8760
- // gets, which is the opposite of what this stage exists to do.
8761
- // "internal" is the honest bucket: the stages enumerate ffmpeg steps
8762
- // and this is the FX render, which is none of them.
8763
- audioFailures: [
8764
- {
8765
- stage: "internal",
8766
- reason: "internal",
8767
- owner: "system",
8768
- retryable: false,
8769
- detail: detail.slice(0, 2e3)
8770
- }
8771
- ]
8772
- };
8773
- }
8774
- assertNotAborted();
8775
- const interrupted = audioResult.failures?.find(
8776
- (failure) => failure.reason === "external_interruption"
8777
- );
8778
- if (interrupted) {
8779
- throw encoderFailureError("Audio processing failed", {
8780
- error: interrupted.detail,
8781
- failureReason: "external_interruption",
8782
- audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
8783
- });
8784
- }
8785
- hasAudio = audioResult.success;
8786
- audioFailures = audioResult.failures;
8787
- if (!hasAudio) {
8788
- audioError = audioResult.error ?? "audio mix failed for an unknown reason";
8789
- for (const failure of audioResult.failures ?? []) {
8790
- log?.warn("Audio track failed", {
8791
- elementId: failure.elementId,
8792
- stage: failure.stage,
8793
- reason: failure.reason,
8794
- owner: failure.owner,
8795
- retryable: failure.retryable,
8796
- detail: failure.detail
8797
- });
8798
- }
8740
+ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
8741
+ let firstBlock;
8742
+ let laterBlocks = Number.NEGATIVE_INFINITY;
8743
+ let partialLine = "";
8744
+ const readLines = (chunk) => {
8745
+ const lines = (partialLine + chunk).split(/\r|\n/);
8746
+ partialLine = lines.pop() ?? "";
8747
+ for (const line of lines) {
8748
+ const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
8749
+ if (!ftpk) continue;
8750
+ const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
8751
+ if (firstBlock === void 0) firstBlock = block;
8752
+ else laterBlocks = Math.max(laterBlocks, block);
8799
8753
  }
8754
+ };
8755
+ const result = await runFfmpeg3(
8756
+ ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
8757
+ { signal, timeout: timeoutMs, onStderr: readLines }
8758
+ );
8759
+ if (!result.success) {
8760
+ throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
8800
8761
  }
8801
- const audioProcessMs = Date.now() - stage3Start;
8762
+ const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
8802
8763
  return {
8803
- audioOutputPath,
8804
- hasAudio,
8805
- audioProcessMs,
8806
- audioError,
8807
- audioFailures
8764
+ peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
8765
+ firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
8766
+ laterBlocks: checkedDbfs(laterBlocks)
8808
8767
  };
8809
8768
  }
8810
-
8811
- // src/services/render/stages/captureStage.ts
8812
- import { existsSync as existsSync12, readdirSync as readdirSync3, statfsSync as statfsSync2 } from "node:fs";
8813
- import { join as join18 } from "node:path";
8814
- import {
8815
- classifyCaptureFailure as classifyCaptureFailure2,
8816
- frameFileExtension,
8817
- captureFrame,
8818
- captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
8819
- verifyDiskDrawElementSamples,
8820
- writeCapturedFrame,
8821
- closeCaptureSession as closeCaptureSession5,
8822
- completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
8823
- createCaptureSession as createCaptureSession5,
8824
- getCapturePerfSummary as getCapturePerfSummary3,
8825
- initializeSession as initializeSession5,
8826
- prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
8827
- } from "@hyperframes/engine";
8828
- function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
8829
- return workerCount > 1;
8769
+ async function measureTruePeakDbfs(audioPath, options) {
8770
+ const { timeoutMs, signal } = options;
8771
+ const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
8772
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
8773
+ const block = 4800;
8774
+ const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
8775
+ if (count === 1) return wholeFile();
8776
+ const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
8777
+ const peers = new AbortController();
8778
+ const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
8779
+ const scans = await Promise.all(
8780
+ Array.from({ length: count }, (_, k) => {
8781
+ const start = k === 0 ? 0 : cut(k) - block;
8782
+ const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
8783
+ return scanTruePeak(
8784
+ audioPath,
8785
+ `atrim=start_sample=${start}${end},`,
8786
+ timeoutMs,
8787
+ segmentSignal
8788
+ );
8789
+ })
8790
+ ).catch((error) => {
8791
+ peers.abort();
8792
+ throw error;
8793
+ });
8794
+ const last = scans[count - 1];
8795
+ const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
8796
+ if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
8797
+ return wholeFile();
8830
8798
  }
8831
- var JPEG_RAW_RGBA_DIVISOR = 8;
8832
- var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
8833
- var DISK_PROJECTION_SAMPLE_FRAMES = 10;
8834
- function estimateDiskCaptureBytes(totalFrames, captureOptions) {
8835
- const scale = captureOptions.deviceScaleFactor ?? 1;
8836
- const outputWidth = Math.ceil(captureOptions.width * scale);
8837
- const outputHeight = Math.ceil(captureOptions.height * scale);
8838
- const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
8839
- return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
8840
- rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
8841
- ) : rawBytes;
8799
+
8800
+ // src/services/render/audioPadTrim.ts
8801
+ var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
8802
+ var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
8803
+ var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
8804
+ var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
8805
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
8806
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
8842
8807
  }
8843
- function freeDiskBytesAt(path) {
8844
- try {
8845
- const stat = statfsSync2(path);
8846
- return stat.bavail * stat.bsize;
8847
- } catch {
8848
- return null;
8808
+ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
8809
+ const delta = targetDurationSeconds - sourceDurationSeconds;
8810
+ const targetSec = formatSeconds(targetDurationSeconds);
8811
+ if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
8812
+ return {
8813
+ operation: "copy",
8814
+ steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
8815
+ cleanupPaths: []
8816
+ };
8849
8817
  }
8850
- }
8851
- function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
8852
- const freeBytes = freeDiskBytes(framesDir);
8853
- const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
8854
- if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
8855
- return { available: true, estimatedBytes, freeBytes };
8818
+ if (delta > 0) {
8819
+ return {
8820
+ operation: "pad",
8821
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
8822
+ cleanupPaths: []
8823
+ };
8856
8824
  }
8857
- return { available: false, estimatedBytes, freeBytes };
8825
+ return {
8826
+ operation: "trim",
8827
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
8828
+ cleanupPaths: []
8829
+ };
8858
8830
  }
8859
- function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
8860
- const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
8861
- if (workerCount > 1 && oneWorker.enabled) {
8862
- 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.`;
8863
- }
8864
- switch (oneWorker.reason) {
8865
- case "format_excluded":
8866
- return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
8867
- case "disabled_by_config":
8868
- return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
8869
- case "duration_cap":
8870
- 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.";
8871
- default:
8872
- return "Free up disk space.";
8873
- }
8831
+ function formatSeconds(sec) {
8832
+ return sec.toFixed(6);
8874
8833
  }
8875
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
8876
- const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
8877
- return new Error(
8878
- `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}`
8879
- );
8834
+ function sanitizeProbeFailure(reason, paths) {
8835
+ const message = reason instanceof Error ? reason.message : String(reason);
8836
+ const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
8837
+ return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
8880
8838
  }
8881
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
8882
- const headroom = inspectDiskCaptureHeadroom(
8883
- framesDir,
8884
- totalFrames,
8885
- captureOptions,
8886
- freeDiskBytes
8887
- );
8888
- if (headroom.available) return;
8889
- throw diskCaptureShortfallError(
8890
- headroom.estimatedBytes,
8891
- headroom.freeBytes,
8892
- framesDir,
8893
- "estimate",
8894
- remedy
8839
+ async function padOrTrimAudioToVideoFrameCount(input) {
8840
+ const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
8841
+ const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
8842
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
8843
+ const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
8844
+ const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
8845
+ const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
8846
+ const [videoResult, audioResult] = await Promise.allSettled([
8847
+ probeVideo(input.videoPath),
8848
+ probeAudio(input.audioPath, input.signal)
8849
+ ]);
8850
+ const probePaths = [input.videoPath, input.audioPath, input.outputPath];
8851
+ if (videoResult.status === "rejected") {
8852
+ return failResult(
8853
+ input.outputPath,
8854
+ 0,
8855
+ audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
8856
+ `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
8857
+ videoResult.reason,
8858
+ probePaths
8859
+ )}`
8860
+ );
8861
+ }
8862
+ if (audioResult.status === "rejected") {
8863
+ return failResult(
8864
+ input.outputPath,
8865
+ 0,
8866
+ 0,
8867
+ `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
8868
+ audioResult.reason,
8869
+ probePaths
8870
+ )}`
8871
+ );
8872
+ }
8873
+ const videoInfo = videoResult.value;
8874
+ const audioInfo = audioResult.value;
8875
+ if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
8876
+ return failResult(
8877
+ input.outputPath,
8878
+ 0,
8879
+ audioInfo.durationSeconds,
8880
+ `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
8881
+ );
8882
+ }
8883
+ const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
8884
+ const plan2 = buildPadTrimAudioPlan(
8885
+ input.audioPath,
8886
+ input.outputPath,
8887
+ audioInfo.durationSeconds,
8888
+ targetDurationSeconds
8895
8889
  );
8896
- }
8897
- function measureCaptureFrameBytes(workDir, framesDir) {
8898
- let total = sampleDirectoryBytes(framesDir);
8899
- if (!existsSync12(workDir)) return total;
8900
- for (const attempt of readdirSync3(workDir)) {
8901
- if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
8902
- total += sampleDirectoryBytes(join18(workDir, attempt));
8903
- continue;
8904
- }
8905
- if (!/^capture-attempt-\d+$/.test(attempt)) continue;
8906
- const attemptDir = join18(workDir, attempt);
8907
- for (const name of readdirSync3(attemptDir)) {
8908
- if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join18(attemptDir, name));
8890
+ try {
8891
+ for (const step of plan2.steps) {
8892
+ const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
8893
+ if (!ffmpegResult.success) {
8894
+ return {
8895
+ success: false,
8896
+ outputPath: input.outputPath,
8897
+ targetDurationSeconds,
8898
+ sourceDurationSeconds: audioInfo.durationSeconds,
8899
+ operation: plan2.operation,
8900
+ error: ffmpegResult.error,
8901
+ failureReason: ffmpegResult.failureReason
8902
+ };
8903
+ }
8909
8904
  }
8905
+ } catch (err) {
8906
+ return {
8907
+ success: false,
8908
+ outputPath: input.outputPath,
8909
+ targetDurationSeconds,
8910
+ sourceDurationSeconds: audioInfo.durationSeconds,
8911
+ operation: plan2.operation,
8912
+ error: `audioPadTrim: failed to materialize ${plan2.operation}: ${err instanceof Error ? err.message : String(err)}`
8913
+ };
8914
+ } finally {
8915
+ for (const path of plan2.cleanupPaths) rmSync7(path, { force: true });
8910
8916
  }
8911
- return total;
8912
- }
8913
- function createDiskCaptureProjection(input) {
8914
- const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
8915
- let checked = false;
8916
- return (capturedFrames) => {
8917
- if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
8918
- const measured = measureBytes();
8919
- if (measured <= 0) return;
8920
- checked = true;
8921
- const remaining = totalFrames - capturedFrames;
8922
- const freeBytes = freeDiskBytes(framesDir);
8923
- if (remaining <= 0 || freeBytes === null) return;
8924
- const needBytes = Math.ceil(measured / capturedFrames * remaining);
8925
- if (needBytes > freeBytes * 0.9) {
8926
- throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
8917
+ let loweredDb = 0;
8918
+ if (probeTruePeak) {
8919
+ const correction = await enforceAacTruePeak({
8920
+ audioPath: input.outputPath,
8921
+ targetDurationSeconds,
8922
+ signal: input.signal,
8923
+ probeTruePeak,
8924
+ encode
8925
+ });
8926
+ if (!correction.success) {
8927
+ return {
8928
+ success: false,
8929
+ outputPath: input.outputPath,
8930
+ targetDurationSeconds,
8931
+ sourceDurationSeconds: audioInfo.durationSeconds,
8932
+ operation: plan2.operation,
8933
+ error: correction.error,
8934
+ failureReason: correction.failureReason
8935
+ };
8927
8936
  }
8937
+ loweredDb = correction.loweredDb ?? 0;
8938
+ }
8939
+ return {
8940
+ success: true,
8941
+ outputPath: input.outputPath,
8942
+ targetDurationSeconds,
8943
+ sourceDurationSeconds: audioInfo.durationSeconds,
8944
+ operation: plan2.operation,
8945
+ ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
8928
8946
  };
8929
8947
  }
8930
- async function runCaptureStage(input) {
8931
- const {
8932
- fileServer,
8933
- workDir,
8934
- framesDir,
8935
- job,
8936
- totalFrames,
8937
- cfg,
8938
- plan: plan2,
8939
- log,
8940
- captureAttempts,
8941
- buildCaptureOptions,
8942
- createRenderVideoFrameInjector,
8943
- abortSignal,
8944
- onProgress,
8945
- frameRange,
8946
- dedupPerfs
8947
- } = input;
8948
- let { probeSession } = input;
8949
- let { workerCount } = plan2;
8950
- const { forceScreenshot } = plan2;
8951
- let lastBrowserConsole = [];
8952
- let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
8953
- const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
8954
- if (frameRange !== void 0) {
8955
- if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
8956
- throw new Error(
8957
- `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
8948
+ async function constrainAacTruePeak(input) {
8949
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
8950
+ return enforceAacTruePeak({
8951
+ audioPath: input.audioPath,
8952
+ targetDurationSeconds: input.targetDurationSeconds,
8953
+ signal: input.signal,
8954
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
8955
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
8956
+ });
8957
+ }
8958
+ async function enforceAacTruePeak(input) {
8959
+ let scratchDir;
8960
+ try {
8961
+ scratchDir = mkdtempSync3(join17(dirname7(input.audioPath), ".true-peak-"));
8962
+ const correctedPath = join17(scratchDir, "audio.m4a");
8963
+ const measurements = [];
8964
+ let attenuationDb = 0;
8965
+ let measuredPath = input.audioPath;
8966
+ for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
8967
+ const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
8968
+ measurements.push(
8969
+ `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
8958
8970
  );
8959
- }
8960
- const rangeFrames = frameRange.endFrame - frameRange.startFrame;
8961
- if (rangeFrames !== totalFrames) {
8962
- throw new Error(
8963
- `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
8971
+ if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
8972
+ return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
8973
+ }
8974
+ if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
8975
+ if (measuredPath !== correctedPath) return { success: true };
8976
+ renameSync4(correctedPath, input.audioPath);
8977
+ return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
8978
+ }
8979
+ if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
8980
+ const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
8981
+ attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
8982
+ const result = await input.encode(
8983
+ input.audioPath,
8984
+ correctedPath,
8985
+ input.targetDurationSeconds,
8986
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
8964
8987
  );
8988
+ if (!result.success) {
8989
+ return {
8990
+ success: false,
8991
+ error: sanitizeProbeFailure(
8992
+ result.error ?? "audioPadTrim: failed to constrain AAC true peak",
8993
+ [input.audioPath, correctedPath]
8994
+ ),
8995
+ failureReason: result.failureReason
8996
+ };
8997
+ }
8998
+ measuredPath = correctedPath;
8965
8999
  }
9000
+ return {
9001
+ success: false,
9002
+ error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
9003
+ };
9004
+ } catch (err) {
9005
+ return {
9006
+ success: false,
9007
+ error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
9008
+ err,
9009
+ scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
9010
+ )}`
9011
+ };
9012
+ } finally {
9013
+ if (scratchDir) rmSync7(scratchDir, { recursive: true, force: true });
8966
9014
  }
8967
- const captureOptions = buildCaptureOptions();
8968
- const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
8969
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
8970
- const checkDiskProjection = createDiskCaptureProjection({
8971
- framesDir,
8972
- totalFrames,
8973
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
8974
- remedy
8975
- });
8976
- if (workerCount > 1) {
8977
- const attempts = await executeDiskCaptureWithAdaptiveRetry({
8978
- serverUrl: fileServer.url,
8979
- workDir,
8980
- framesDir,
8981
- totalFrames,
8982
- initialWorkerCount: workerCount,
8983
- allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
8984
- frameExt: frameFileExtension(captureOptions.format),
8985
- captureOptions,
8986
- createBeforeCaptureHook: createRenderVideoFrameInjector,
8987
- abortSignal,
8988
- frameRangeStart: frameRange?.startFrame,
8989
- dedupPerfs,
8990
- onProgress: (progress) => {
8991
- if (progress.latestWorkerPhase) {
8992
- if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
8993
- return;
8994
- }
8995
- if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
8996
- job.framesRendered = progress.capturedFrames;
8997
- checkDiskProjection(progress.capturedFrames);
8998
- const frameProgress = progress.capturedFrames / progress.totalFrames;
8999
- reportFrameProgress(
9000
- job,
9001
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
9002
- Math.round(25 + frameProgress * 45),
9003
- onProgress,
9004
- progress.capturedFrames,
9005
- progress.totalFrames
9006
- );
9007
- },
9008
- cfg: captureCfg,
9009
- log
9010
- });
9011
- captureAttempts.push(...attempts);
9012
- const lastAttempt = attempts[attempts.length - 1];
9013
- if (lastAttempt) {
9014
- workerCount = lastAttempt.workers;
9015
+ }
9016
+ function failResult(outputPath, target, source, error) {
9017
+ return {
9018
+ success: false,
9019
+ outputPath,
9020
+ targetDurationSeconds: target,
9021
+ sourceDurationSeconds: source,
9022
+ operation: "copy",
9023
+ error
9024
+ };
9025
+ }
9026
+ async function defaultProbeVideoFrameInfo(videoPath, signal) {
9027
+ const fastInfo = await runFfprobeJson(
9028
+ [
9029
+ "-v",
9030
+ "error",
9031
+ "-select_streams",
9032
+ "v:0",
9033
+ "-show_entries",
9034
+ "stream=nb_frames,r_frame_rate",
9035
+ "-of",
9036
+ "json",
9037
+ "--",
9038
+ videoPath
9039
+ ],
9040
+ signal
9041
+ );
9042
+ let stream = fastInfo.streams?.[0];
9043
+ const fastCount = Number(stream?.nb_frames);
9044
+ if (stream && Number.isFinite(fastCount) && fastCount > 0) {
9045
+ return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
9046
+ }
9047
+ const slowInfo = await runFfprobeJson(
9048
+ [
9049
+ "-v",
9050
+ "error",
9051
+ "-select_streams",
9052
+ "v:0",
9053
+ "-count_packets",
9054
+ "-show_entries",
9055
+ "stream=nb_read_packets,r_frame_rate",
9056
+ "-of",
9057
+ "json",
9058
+ "--",
9059
+ videoPath
9060
+ ],
9061
+ signal
9062
+ );
9063
+ stream = slowInfo.streams?.[0];
9064
+ if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
9065
+ const slowCount = Number(stream.nb_read_packets);
9066
+ if (!Number.isFinite(slowCount) || slowCount <= 0) {
9067
+ throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
9068
+ }
9069
+ return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
9070
+ }
9071
+ function parseFrameRate(rate) {
9072
+ const [n, d] = rate.split("/");
9073
+ const fpsNum = Number(n);
9074
+ const fpsDen = d === void 0 ? 1 : Number(d);
9075
+ if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
9076
+ throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
9077
+ }
9078
+ return { fpsNum, fpsDen };
9079
+ }
9080
+ async function defaultProbeAudioInfo(audioPath, signal) {
9081
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
9082
+ return {
9083
+ durationSeconds: metadata.durationSeconds,
9084
+ sampleRate: metadata.sampleRate,
9085
+ channels: metadata.channels,
9086
+ audioCodec: metadata.audioCodec
9087
+ };
9088
+ }
9089
+ async function defaultRunFfmpeg(args, timeoutMs, signal) {
9090
+ return toStepResult(await runFfmpeg4(args, { signal, timeout: timeoutMs }), timeoutMs);
9091
+ }
9092
+ function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
9093
+ return async (inputPath, outputPath, durationSeconds, filter) => {
9094
+ const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
9095
+ return toStepResult(
9096
+ await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
9097
+ timeoutMs
9098
+ );
9099
+ };
9100
+ }
9101
+ function toStepResult(result, timeoutMs) {
9102
+ if (result.success) return { success: true };
9103
+ return {
9104
+ success: false,
9105
+ error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
9106
+ failureReason: result.failureReason
9107
+ };
9108
+ }
9109
+ async function runFfprobeJson(args, signal) {
9110
+ if (!args.includes("--")) {
9111
+ throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
9112
+ }
9113
+ const proc = spawn(getFfprobeBinary(), args, {
9114
+ stdio: ["ignore", "pipe", "pipe"],
9115
+ windowsHide: true
9116
+ });
9117
+ trackChildProcess(proc);
9118
+ let stdout = "";
9119
+ proc.stdout.on("data", (data) => {
9120
+ stdout += data.toString();
9121
+ });
9122
+ const managed = new ManagedChildProcess(proc, {
9123
+ signal,
9124
+ deadlineAtMs: Date.now() + 3e4
9125
+ });
9126
+ const outcome = await managed.wait();
9127
+ if (outcome.reason === "spawn_error") {
9128
+ if (outcome.error?.code === "ENOENT") {
9129
+ throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
9015
9130
  }
9016
- if (probeSession) {
9017
- captureBeyondViewport = probeSession.options.captureBeyondViewport;
9018
- lastBrowserConsole = probeSession.browserConsoleBuffer;
9019
- await closeCaptureSession5(probeSession);
9020
- probeSession = null;
9131
+ throw outcome.error ?? new Error(outcome.stderr);
9132
+ }
9133
+ if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
9134
+ const probed = args[args.length - 1];
9135
+ const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
9136
+ throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
9137
+ }
9138
+ try {
9139
+ return JSON.parse(stdout);
9140
+ } catch (err) {
9141
+ throw new Error(`Failed to parse ffprobe output: ${err.message}`);
9142
+ }
9143
+ }
9144
+
9145
+ // src/services/render/stages/audioStage.ts
9146
+ function assertAudioNotInterrupted(failures) {
9147
+ const interrupted = failures?.find((failure) => failure.reason === "external_interruption");
9148
+ if (!interrupted) return;
9149
+ throw encoderFailureError("Audio processing failed", {
9150
+ error: interrupted.detail,
9151
+ failureReason: "external_interruption",
9152
+ audioFailures: audioFailureDiagnostics([
9153
+ interrupted,
9154
+ ...(failures ?? []).filter((failure) => failure !== interrupted)
9155
+ ])
9156
+ });
9157
+ }
9158
+ async function runAudioStage(input) {
9159
+ const stageStart = Date.now();
9160
+ const pcmSourcePath = join18(input.workDir, "audio-source.wav");
9161
+ const duration = input.totalFrames * input.fps.den / input.fps.num;
9162
+ try {
9163
+ const result = await mixAudioForDelivery(input, duration, pcmSourcePath);
9164
+ let audioLoweredDb;
9165
+ if (result.hasAudio) {
9166
+ const peak = await constrainAacTruePeak({
9167
+ audioPath: result.audioOutputPath,
9168
+ sourcePath: pcmSourcePath,
9169
+ targetDurationSeconds: duration,
9170
+ signal: input.abortSignal,
9171
+ timeoutMs: input.ffmpegProcessTimeout
9172
+ });
9173
+ if (!peak.success) throw encoderFailureError("Audio true-peak check failed", peak);
9174
+ audioLoweredDb = peak.loweredDb;
9021
9175
  }
9022
- } else {
9023
- const sequential = await runSequentialDiskCapture(
9024
- input,
9025
- captureOptions,
9026
- captureCfg,
9027
- checkDiskProjection
9028
- );
9029
- probeSession = null;
9030
- lastBrowserConsole = sequential.lastBrowserConsole;
9031
- captureBeyondViewport = sequential.captureBeyondViewport;
9176
+ return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
9177
+ } finally {
9178
+ rmSync8(pcmSourcePath, { force: true });
9032
9179
  }
9033
- return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
9034
9180
  }
9035
- async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
9181
+ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9036
9182
  const {
9037
- fileServer,
9038
- framesDir,
9039
- totalFrames,
9040
- log,
9041
- createRenderVideoFrameInjector,
9183
+ projectDir,
9184
+ workDir,
9185
+ compiledDir,
9186
+ ffmpegProcessTimeout,
9187
+ audioGain,
9188
+ audios,
9189
+ masterFxChain,
9190
+ urlByAbsentLocalPath,
9042
9191
  abortSignal,
9043
9192
  assertNotAborted,
9044
- frameRange
9193
+ log
9045
9194
  } = input;
9046
- let { probeSession } = input;
9047
- let lastBrowserConsole = [];
9048
- let captureBeyondViewport;
9049
- const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
9050
- const rangeFrames = rangeEnd - rangeStart;
9051
- const frameExt = frameFileExtension(captureOptions.format);
9052
- let transientRetriesUsed = 0;
9053
- let resumeFrom = 0;
9054
- while (true) {
9055
- const videoInjector = createRenderVideoFrameInjector();
9056
- const session = probeSession ?? await createCaptureSession5(
9057
- fileServer.url,
9058
- framesDir,
9059
- captureOptions,
9060
- videoInjector,
9061
- captureCfg
9062
- );
9063
- captureBeyondViewport = session.options.captureBeyondViewport;
9195
+ const audioOutputPath = join18(workDir, MIXED_AUDIO_FILENAME);
9196
+ let hasAudio = false;
9197
+ let audioError;
9198
+ let audioFailures;
9199
+ if (audios.length > 0) {
9200
+ let audioResult;
9064
9201
  try {
9065
- if (probeSession) {
9066
- prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
9067
- probeSession = null;
9068
- }
9069
- await ensureSessionReady(session);
9070
- assertNotAborted();
9071
- lastBrowserConsole = session.browserConsoleBuffer;
9072
- await captureSessionFrames(session, {
9073
- input,
9074
- rangeStart,
9075
- rangeEnd,
9076
- resumeFrom,
9077
- checkDiskProjection
9078
- });
9079
- break;
9080
- } catch (error) {
9081
- lastBrowserConsole = session.browserConsoleBuffer;
9082
- const resume = planTransientResume(error, {
9202
+ audioResult = await processCompositionAudio(
9203
+ audios,
9204
+ projectDir,
9205
+ join18(workDir, "audio-work"),
9206
+ audioOutputPath,
9207
+ duration,
9083
9208
  abortSignal,
9084
- rangeFrames,
9085
- framesDir,
9086
- frameExt,
9087
- retriesUsed: transientRetriesUsed
9088
- });
9089
- if (resume !== null) {
9090
- transientRetriesUsed++;
9091
- resumeFrom = resume;
9092
- log.warn(
9093
- "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
9094
- {
9095
- resumeFrom,
9096
- rangeFrames,
9097
- transientRetriesUsed,
9098
- error: error instanceof Error ? error.message : String(error)
9099
- }
9100
- );
9101
- continue;
9209
+ {
9210
+ ffmpegProcessTimeout,
9211
+ audioGain,
9212
+ masterFxChain,
9213
+ urlByAbsentLocalPath,
9214
+ keepEncoderInputAt: pcmSourcePath
9215
+ },
9216
+ compiledDir
9217
+ );
9218
+ } catch (err) {
9219
+ assertNotAborted();
9220
+ const detail = err instanceof Error ? err.message : String(err);
9221
+ return {
9222
+ audioOutputPath,
9223
+ hasAudio: false,
9224
+ audioError: detail,
9225
+ // Synthesised rather than left undefined. The warning policy reads
9226
+ // owner, retryability, reason and stage off this list, so reporting the
9227
+ // FATAL failure — the one that took the whole mix down — with an empty
9228
+ // one gave it strictly less classification than a single dropped track
9229
+ // gets, which is the opposite of what this stage exists to do.
9230
+ // "internal" is the honest bucket: the stages enumerate ffmpeg steps
9231
+ // and this is the FX render, which is none of them.
9232
+ audioFailures: [
9233
+ {
9234
+ stage: "internal",
9235
+ reason: "internal",
9236
+ owner: "system",
9237
+ retryable: false,
9238
+ detail: detail.slice(0, 2e3)
9239
+ }
9240
+ ]
9241
+ };
9242
+ }
9243
+ assertNotAborted();
9244
+ assertAudioNotInterrupted(audioResult.failures);
9245
+ hasAudio = audioResult.success;
9246
+ audioFailures = audioResult.failures;
9247
+ if (!hasAudio) {
9248
+ audioError = audioResult.error ?? "audio mix failed for an unknown reason";
9249
+ for (const failure of audioResult.failures ?? []) {
9250
+ log?.warn("Audio track failed", {
9251
+ elementId: failure.elementId,
9252
+ stage: failure.stage,
9253
+ reason: failure.reason,
9254
+ owner: failure.owner,
9255
+ retryable: failure.retryable,
9256
+ detail: failure.detail
9257
+ });
9102
9258
  }
9103
- throw wrapCaptureStageError(error, lastBrowserConsole);
9104
- } finally {
9105
- lastBrowserConsole = session.browserConsoleBuffer;
9106
- await closeCaptureSession5(session);
9107
9259
  }
9108
9260
  }
9109
- return { lastBrowserConsole, captureBeyondViewport };
9261
+ return {
9262
+ audioOutputPath,
9263
+ hasAudio,
9264
+ audioError,
9265
+ audioFailures
9266
+ };
9110
9267
  }
9111
- function resolveCaptureRange(frameRange, totalFrames) {
9112
- return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
9268
+
9269
+ // src/services/render/stages/captureStage.ts
9270
+ import { existsSync as existsSync12, readdirSync as readdirSync3, statfsSync as statfsSync2 } from "node:fs";
9271
+ import { join as join19 } from "node:path";
9272
+ import {
9273
+ classifyCaptureFailure as classifyCaptureFailure2,
9274
+ frameFileExtension,
9275
+ captureFrame,
9276
+ captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
9277
+ verifyDiskDrawElementSamples,
9278
+ writeCapturedFrame,
9279
+ closeCaptureSession as closeCaptureSession5,
9280
+ completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
9281
+ createCaptureSession as createCaptureSession5,
9282
+ getCapturePerfSummary as getCapturePerfSummary3,
9283
+ initializeSession as initializeSession5,
9284
+ prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
9285
+ } from "@hyperframes/engine";
9286
+ function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
9287
+ return workerCount > 1;
9113
9288
  }
9114
- function planTransientResume(error, ctx) {
9115
- const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
9116
- const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
9117
- const [firstMissing] = missing;
9118
- if (!firstMissing) return null;
9119
- return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
9289
+ var JPEG_RAW_RGBA_DIVISOR = 8;
9290
+ var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
9291
+ var DISK_PROJECTION_SAMPLE_FRAMES = 10;
9292
+ function estimateDiskCaptureBytes(totalFrames, captureOptions) {
9293
+ const scale = captureOptions.deviceScaleFactor ?? 1;
9294
+ const outputWidth = Math.ceil(captureOptions.width * scale);
9295
+ const outputHeight = Math.ceil(captureOptions.height * scale);
9296
+ const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
9297
+ return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
9298
+ rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
9299
+ ) : rawBytes;
9120
9300
  }
9121
- async function ensureSessionReady(session) {
9122
- if (!session.isInitialized) {
9123
- await initializeSession5(session);
9124
- return;
9125
- }
9126
- if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
9127
- await completeDeferredDrawElementInit3(session);
9301
+ function freeDiskBytesAt(path) {
9302
+ try {
9303
+ const stat = statfsSync2(path);
9304
+ return stat.bavail * stat.bsize;
9305
+ } catch {
9306
+ return null;
9128
9307
  }
9129
9308
  }
9130
- async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
9131
- const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
9132
- const rangeFrames = rangeEnd - rangeStart;
9133
- const reportFrame = (fileIndex) => {
9134
- job.framesRendered = fileIndex + 1;
9135
- checkDiskProjection(fileIndex + 1);
9136
- reportFrameProgress(
9137
- job,
9138
- `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
9139
- Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
9140
- onProgress,
9141
- fileIndex + 1,
9142
- rangeFrames
9143
- );
9144
- };
9145
- if (session.workerEncodeEnabled) {
9146
- let prev = null;
9147
- const drainPrev = async () => {
9148
- if (!prev) return;
9149
- assertNotAborted();
9150
- const buf = await prev.encodeResult;
9151
- writeCapturedFrame(session, prev.fileIndex, buf);
9152
- reportFrame(prev.fileIndex);
9153
- };
9154
- for (let i = resumeFrom; i < rangeFrames; i++) {
9155
- assertNotAborted();
9156
- const absoluteIdx = rangeStart + i;
9157
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9158
- const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
9159
- await drainPrev();
9160
- prev = { fileIndex: i, encodeResult };
9161
- }
9162
- await drainPrev();
9163
- } else {
9164
- for (let i = resumeFrom; i < rangeFrames; i++) {
9165
- assertNotAborted();
9166
- const absoluteIdx = rangeStart + i;
9167
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9168
- await captureFrame(session, i, time);
9169
- reportFrame(i);
9170
- }
9309
+ function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
9310
+ const freeBytes = freeDiskBytes(framesDir);
9311
+ const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
9312
+ if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
9313
+ return { available: true, estimatedBytes, freeBytes };
9171
9314
  }
9172
- await verifyDiskDrawElementSamples(
9173
- session,
9174
- {
9175
- workerId: 0,
9176
- startFrame: rangeStart + resumeFrom,
9177
- endFrame: rangeEnd,
9178
- outputDir: framesDir,
9179
- outputFrameOffset: rangeStart
9180
- },
9181
- false
9182
- );
9183
- dedupPerfs.push(getCapturePerfSummary3(session));
9315
+ return { available: false, estimatedBytes, freeBytes };
9184
9316
  }
9185
-
9186
- // src/services/render/stages/captureHdrStage.ts
9187
- import { existsSync as existsSync14, mkdirSync as mkdirSync7 } from "node:fs";
9188
- import { join as join22 } from "node:path";
9189
- import {
9190
- closeCaptureSession as closeCaptureSession7,
9191
- cloneCaptureWarnings as cloneCaptureWarnings2,
9192
- createCaptureSession as createCaptureSession7,
9193
- initTransparentBackground as initTransparentBackground2,
9194
- initializeSession as initializeSession7,
9195
- spawnStreamingEncoder as spawnStreamingEncoder3
9196
- } from "@hyperframes/engine";
9197
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
9198
-
9199
- // src/services/hdrImageTransferCache.ts
9200
- import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
9201
- var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
9202
- function createHdrImageTransferCache(options = {}) {
9203
- const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
9204
- if (!Number.isInteger(maxBytes) || maxBytes < 0) {
9205
- throw new Error(
9206
- `createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
9207
- );
9317
+ function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
9318
+ const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
9319
+ if (workerCount > 1 && oneWorker.enabled) {
9320
+ 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.`;
9208
9321
  }
9209
- const entries = /* @__PURE__ */ new Map();
9210
- let totalBytes = 0;
9211
- function makeKey(imageId, targetTransfer) {
9212
- return `${imageId}|${targetTransfer}`;
9322
+ switch (oneWorker.reason) {
9323
+ case "format_excluded":
9324
+ return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
9325
+ case "disabled_by_config":
9326
+ return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
9327
+ case "duration_cap":
9328
+ 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.";
9329
+ default:
9330
+ return "Free up disk space.";
9213
9331
  }
9214
- function evictUntilRoom(needed) {
9215
- while (totalBytes + needed > maxBytes && entries.size > 0) {
9216
- const lruKey = entries.keys().next().value;
9217
- if (lruKey === void 0) break;
9218
- const evicted = entries.get(lruKey);
9219
- if (evicted) totalBytes -= evicted.byteLength;
9220
- entries.delete(lruKey);
9332
+ }
9333
+ function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
9334
+ const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
9335
+ return new Error(
9336
+ `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}`
9337
+ );
9338
+ }
9339
+ function measureCaptureFrameBytes(workDir, framesDir) {
9340
+ let total = sampleDirectoryBytes(framesDir);
9341
+ if (!existsSync12(workDir)) return total;
9342
+ for (const attempt of readdirSync3(workDir)) {
9343
+ if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
9344
+ total += sampleDirectoryBytes(join19(workDir, attempt));
9345
+ continue;
9346
+ }
9347
+ if (!/^capture-attempt-\d+$/.test(attempt)) continue;
9348
+ const attemptDir = join19(workDir, attempt);
9349
+ for (const name of readdirSync3(attemptDir)) {
9350
+ if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join19(attemptDir, name));
9221
9351
  }
9222
9352
  }
9223
- return {
9224
- getConverted(imageId, sourceTransfer, targetTransfer, source) {
9225
- if (sourceTransfer === targetTransfer) {
9226
- return source;
9227
- }
9228
- if (maxBytes === 0) {
9229
- const fresh = Buffer.from(source);
9230
- convertTransfer2(fresh, sourceTransfer, targetTransfer);
9231
- return fresh;
9232
- }
9233
- const key = makeKey(imageId, targetTransfer);
9234
- const existing = entries.get(key);
9235
- if (existing) {
9236
- entries.delete(key);
9237
- entries.set(key, existing);
9238
- return existing;
9239
- }
9240
- const converted = Buffer.from(source);
9241
- convertTransfer2(converted, sourceTransfer, targetTransfer);
9242
- if (converted.byteLength > maxBytes) {
9243
- return converted;
9244
- }
9245
- evictUntilRoom(converted.byteLength);
9246
- entries.set(key, converted);
9247
- totalBytes += converted.byteLength;
9248
- return converted;
9249
- },
9250
- size() {
9251
- return entries.size;
9252
- },
9253
- bytesUsed() {
9254
- return totalBytes;
9353
+ return total;
9354
+ }
9355
+ function createDiskCaptureProjection(input) {
9356
+ const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
9357
+ let checked = false;
9358
+ return (capturedFrames) => {
9359
+ if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
9360
+ const measured = measureBytes();
9361
+ if (measured <= 0) return;
9362
+ checked = true;
9363
+ const remaining = totalFrames - capturedFrames;
9364
+ const freeBytes = freeDiskBytes(framesDir);
9365
+ if (remaining <= 0 || freeBytes === null) return;
9366
+ const needBytes = Math.ceil(measured / capturedFrames * remaining);
9367
+ if (needBytes > freeBytes * 0.9) {
9368
+ throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
9255
9369
  }
9256
9370
  };
9257
9371
  }
9258
-
9259
- // src/services/render/stages/captureHdrSequentialLoop.ts
9260
- import { join as join19 } from "node:path";
9261
- import {
9262
- TRANSITIONS,
9263
- crossfade
9264
- } from "@hyperframes/engine";
9265
- async function runSequentialLayeredFrameLoop(input) {
9372
+ async function runCaptureStage(input) {
9266
9373
  const {
9374
+ fileServer,
9375
+ workDir,
9376
+ framesDir,
9267
9377
  job,
9268
- log,
9269
- width,
9270
- height,
9271
9378
  totalFrames,
9272
- nativeHdrIds,
9273
- nativeHdrImageIds,
9274
- hdrCompositeCtx,
9275
- hdrPerf,
9276
- hdrEncoder,
9277
- domSession,
9278
- transitionRanges,
9279
- sceneElements,
9280
- compositeTransfer,
9281
- hdrTargetTransfer,
9282
- hdrVideoEndTimes,
9283
- cleanedUpVideos,
9284
- hdrVideoFrameSources,
9285
- debugDumpEnabled,
9286
- debugDumpDir,
9287
- assertNotAborted,
9288
- onProgress
9379
+ cfg,
9380
+ plan: plan2,
9381
+ log,
9382
+ captureAttempts,
9383
+ buildCaptureOptions,
9384
+ createRenderVideoFrameInjector,
9385
+ abortSignal,
9386
+ onProgress,
9387
+ frameRange,
9388
+ dedupPerfs
9289
9389
  } = input;
9290
- const beforeCaptureHook = domSession.onBeforeCapture;
9291
- const bufSize = width * height * 6;
9292
- const hasTransitions = transitionRanges.length > 0;
9293
- const transitionBuffers = hasTransitions ? {
9294
- bufferA: Buffer.alloc(bufSize),
9295
- bufferB: Buffer.alloc(bufSize),
9296
- output: Buffer.alloc(bufSize)
9297
- } : null;
9298
- const normalCanvas = Buffer.alloc(bufSize);
9299
- for (let i = 0; i < totalFrames; i++) {
9300
- assertNotAborted();
9301
- const time = i * job.config.fps.den / job.config.fps.num;
9302
- if (hdrPerf) hdrPerf.frames += 1;
9303
- const stackingInfo = await seekInjectAndQueryStacking(
9304
- domSession.page,
9305
- time,
9306
- beforeCaptureHook,
9307
- nativeHdrIds,
9308
- hdrPerf,
9309
- "frameSeekMs",
9310
- "frameInjectMs",
9311
- "stackingQueryMs"
9312
- );
9313
- const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
9314
- if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
9315
- const hdrEl = stackingInfo.find((e) => e.isHdr);
9316
- log.debug("[Render] HDR layer composite frame", {
9317
- frame: i,
9318
- time: time.toFixed(2),
9319
- hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
9320
- stackingCount: stackingInfo.length,
9321
- activeTransition: activeTransition?.shader
9322
- });
9323
- }
9324
- if (activeTransition && transitionBuffers) {
9325
- if (hdrPerf) hdrPerf.transitionFrames += 1;
9326
- const transitionTimingStart = Date.now();
9327
- const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
9328
- const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
9329
- const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
9330
- timeHdrPhase(hdrPerf, "canvasClearMs", () => {
9331
- transitionBuffers.bufferA.fill(0);
9332
- transitionBuffers.bufferB.fill(0);
9333
- });
9334
- const sceneCaptures = [
9335
- [transitionBuffers.bufferA, sceneAIds],
9336
- [transitionBuffers.bufferB, sceneBIds]
9337
- ];
9338
- for (const [sceneBuf, sceneIds] of sceneCaptures) {
9339
- assertNotAborted();
9340
- await captureSceneIntoBuffer({
9341
- session: domSession,
9342
- sceneBuf,
9343
- sceneIds,
9344
- stackingInfo,
9345
- time,
9346
- width,
9347
- height,
9348
- nativeHdrIds,
9349
- nativeHdrImageIds,
9350
- beforeCaptureHook,
9351
- hdrCompositeCtx,
9352
- compositeTransfer,
9353
- hdrTargetTransfer,
9354
- hdrPerf,
9355
- log,
9356
- frameIdx: i
9357
- });
9358
- }
9359
- const shaderName = activeTransition.shader;
9360
- const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
9361
- transitionFn(
9362
- transitionBuffers.bufferA,
9363
- transitionBuffers.bufferB,
9364
- transitionBuffers.output,
9365
- width,
9366
- height,
9367
- progress
9368
- );
9369
- addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
9370
- await timeHdrPhaseAsync(
9371
- hdrPerf,
9372
- "encoderWriteMs",
9373
- async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
9374
- );
9375
- } else {
9376
- if (hdrPerf) hdrPerf.normalFrames += 1;
9377
- timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
9378
- await timeHdrPhaseAsync(
9379
- hdrPerf,
9380
- "normalCompositeMs",
9381
- () => compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, void 0, i)
9390
+ let { probeSession } = input;
9391
+ let { workerCount } = plan2;
9392
+ const { forceScreenshot } = plan2;
9393
+ let lastBrowserConsole = [];
9394
+ let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
9395
+ const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
9396
+ if (frameRange !== void 0) {
9397
+ if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
9398
+ throw new Error(
9399
+ `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
9382
9400
  );
9383
- if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9384
- writeFileExclusiveSync(
9385
- join19(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9386
- normalCanvas
9387
- );
9388
- }
9389
- await timeHdrPhaseAsync(
9390
- hdrPerf,
9391
- "encoderWriteMs",
9392
- async () => ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i, hdrEncoder)
9401
+ }
9402
+ const rangeFrames = frameRange.endFrame - frameRange.startFrame;
9403
+ if (rangeFrames !== totalFrames) {
9404
+ throw new Error(
9405
+ `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
9393
9406
  );
9394
9407
  }
9395
- cleanupEndedHdrVideos({
9396
- time,
9397
- activeTransition,
9398
- hdrVideoEndTimes,
9399
- cleanedUpVideos,
9400
- hdrVideoFrameSources,
9401
- sceneElements,
9408
+ }
9409
+ const captureOptions = buildCaptureOptions();
9410
+ const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
9411
+ const checkDiskProjection = createDiskCaptureProjection({
9412
+ framesDir,
9413
+ totalFrames,
9414
+ measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
9415
+ remedy
9416
+ });
9417
+ if (workerCount > 1) {
9418
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
9419
+ serverUrl: fileServer.url,
9420
+ workDir,
9421
+ framesDir,
9422
+ totalFrames,
9423
+ initialWorkerCount: workerCount,
9424
+ allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
9425
+ frameExt: frameFileExtension(captureOptions.format),
9426
+ captureOptions,
9427
+ createBeforeCaptureHook: createRenderVideoFrameInjector,
9428
+ abortSignal,
9429
+ frameRangeStart: frameRange?.startFrame,
9430
+ dedupPerfs,
9431
+ onProgress: (progress) => {
9432
+ if (progress.latestWorkerPhase) {
9433
+ if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
9434
+ return;
9435
+ }
9436
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
9437
+ job.framesRendered = progress.capturedFrames;
9438
+ checkDiskProjection(progress.capturedFrames);
9439
+ const frameProgress = progress.capturedFrames / progress.totalFrames;
9440
+ reportFrameProgress(
9441
+ job,
9442
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
9443
+ Math.round(25 + frameProgress * 45),
9444
+ onProgress,
9445
+ progress.capturedFrames,
9446
+ progress.totalFrames
9447
+ );
9448
+ },
9449
+ cfg: captureCfg,
9402
9450
  log
9403
9451
  });
9404
- job.framesRendered = i + 1;
9405
- reportFrameProgress(
9406
- job,
9407
- `Layered composite frame ${i + 1}/${job.totalFrames}`,
9408
- Math.round(25 + (i + 1) / totalFrames * 55),
9409
- onProgress,
9410
- i + 1,
9411
- totalFrames
9452
+ captureAttempts.push(...attempts);
9453
+ const lastAttempt = attempts[attempts.length - 1];
9454
+ if (lastAttempt) {
9455
+ workerCount = lastAttempt.workers;
9456
+ }
9457
+ if (probeSession) {
9458
+ captureBeyondViewport = probeSession.options.captureBeyondViewport;
9459
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
9460
+ await closeCaptureSession5(probeSession);
9461
+ probeSession = null;
9462
+ }
9463
+ } else {
9464
+ const sequential = await runSequentialDiskCapture(
9465
+ input,
9466
+ captureOptions,
9467
+ captureCfg,
9468
+ checkDiskProjection
9412
9469
  );
9470
+ probeSession = null;
9471
+ lastBrowserConsole = sequential.lastBrowserConsole;
9472
+ captureBeyondViewport = sequential.captureBeyondViewport;
9473
+ }
9474
+ return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
9475
+ }
9476
+ async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
9477
+ const {
9478
+ fileServer,
9479
+ framesDir,
9480
+ totalFrames,
9481
+ log,
9482
+ createRenderVideoFrameInjector,
9483
+ abortSignal,
9484
+ assertNotAborted,
9485
+ frameRange
9486
+ } = input;
9487
+ let { probeSession } = input;
9488
+ let lastBrowserConsole = [];
9489
+ let captureBeyondViewport;
9490
+ const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
9491
+ const rangeFrames = rangeEnd - rangeStart;
9492
+ const frameExt = frameFileExtension(captureOptions.format);
9493
+ let transientRetriesUsed = 0;
9494
+ let resumeFrom = 0;
9495
+ while (true) {
9496
+ const videoInjector = createRenderVideoFrameInjector();
9497
+ const session = probeSession ?? await createCaptureSession5(
9498
+ fileServer.url,
9499
+ framesDir,
9500
+ captureOptions,
9501
+ videoInjector,
9502
+ captureCfg
9503
+ );
9504
+ captureBeyondViewport = session.options.captureBeyondViewport;
9505
+ try {
9506
+ if (probeSession) {
9507
+ prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
9508
+ probeSession = null;
9509
+ }
9510
+ await ensureSessionReady(session);
9511
+ assertNotAborted();
9512
+ lastBrowserConsole = session.browserConsoleBuffer;
9513
+ await captureSessionFrames(session, {
9514
+ input,
9515
+ rangeStart,
9516
+ rangeEnd,
9517
+ resumeFrom,
9518
+ checkDiskProjection
9519
+ });
9520
+ break;
9521
+ } catch (error) {
9522
+ lastBrowserConsole = session.browserConsoleBuffer;
9523
+ const resume = planTransientResume(error, {
9524
+ abortSignal,
9525
+ rangeFrames,
9526
+ framesDir,
9527
+ frameExt,
9528
+ retriesUsed: transientRetriesUsed
9529
+ });
9530
+ if (resume !== null) {
9531
+ transientRetriesUsed++;
9532
+ resumeFrom = resume;
9533
+ log.warn(
9534
+ "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
9535
+ {
9536
+ resumeFrom,
9537
+ rangeFrames,
9538
+ transientRetriesUsed,
9539
+ error: error instanceof Error ? error.message : String(error)
9540
+ }
9541
+ );
9542
+ continue;
9543
+ }
9544
+ throw wrapCaptureStageError(error, lastBrowserConsole);
9545
+ } finally {
9546
+ lastBrowserConsole = session.browserConsoleBuffer;
9547
+ await closeCaptureSession5(session);
9548
+ }
9549
+ }
9550
+ return { lastBrowserConsole, captureBeyondViewport };
9551
+ }
9552
+ function resolveCaptureRange(frameRange, totalFrames) {
9553
+ return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
9554
+ }
9555
+ function planTransientResume(error, ctx) {
9556
+ const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
9557
+ const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
9558
+ const [firstMissing] = missing;
9559
+ if (!firstMissing) return null;
9560
+ return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
9561
+ }
9562
+ async function ensureSessionReady(session) {
9563
+ if (!session.isInitialized) {
9564
+ await initializeSession5(session);
9565
+ return;
9566
+ }
9567
+ if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
9568
+ await completeDeferredDrawElementInit3(session);
9569
+ }
9570
+ }
9571
+ async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
9572
+ const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
9573
+ const rangeFrames = rangeEnd - rangeStart;
9574
+ const reportFrame = (fileIndex) => {
9575
+ job.framesRendered = fileIndex + 1;
9576
+ checkDiskProjection(fileIndex + 1);
9577
+ reportFrameProgress(
9578
+ job,
9579
+ `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
9580
+ Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
9581
+ onProgress,
9582
+ fileIndex + 1,
9583
+ rangeFrames
9584
+ );
9585
+ };
9586
+ if (session.workerEncodeEnabled) {
9587
+ let prev = null;
9588
+ const drainPrev = async () => {
9589
+ if (!prev) return;
9590
+ assertNotAborted();
9591
+ const buf = await prev.encodeResult;
9592
+ writeCapturedFrame(session, prev.fileIndex, buf);
9593
+ reportFrame(prev.fileIndex);
9594
+ };
9595
+ for (let i = resumeFrom; i < rangeFrames; i++) {
9596
+ assertNotAborted();
9597
+ const absoluteIdx = rangeStart + i;
9598
+ const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9599
+ const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
9600
+ await drainPrev();
9601
+ prev = { fileIndex: i, encodeResult };
9602
+ }
9603
+ await drainPrev();
9604
+ } else {
9605
+ for (let i = resumeFrom; i < rangeFrames; i++) {
9606
+ assertNotAborted();
9607
+ const absoluteIdx = rangeStart + i;
9608
+ const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9609
+ await captureFrame(session, i, time);
9610
+ reportFrame(i);
9611
+ }
9413
9612
  }
9613
+ await verifyDiskDrawElementSamples(
9614
+ session,
9615
+ {
9616
+ workerId: 0,
9617
+ startFrame: rangeStart + resumeFrom,
9618
+ endFrame: rangeEnd,
9619
+ outputDir: framesDir,
9620
+ outputFrameOffset: rangeStart
9621
+ },
9622
+ false
9623
+ );
9624
+ dedupPerfs.push(getCapturePerfSummary3(session));
9414
9625
  }
9415
9626
 
9416
- // src/services/render/stages/captureHdrHybridLoop.ts
9417
- import { join as join21 } from "node:path";
9627
+ // src/services/render/stages/captureHdrStage.ts
9628
+ import { existsSync as existsSync14, mkdirSync as mkdirSync7 } from "node:fs";
9629
+ import { join as join23 } from "node:path";
9418
9630
  import {
9419
- TRANSITIONS as TRANSITIONS2,
9420
- closeCaptureSession as closeCaptureSession6,
9421
- createCaptureSession as createCaptureSession6,
9422
- createFrameReorderBuffer as createFrameReorderBuffer2,
9423
- crossfade as crossfade2,
9424
- initTransparentBackground,
9425
- initializeSession as initializeSession6
9631
+ closeCaptureSession as closeCaptureSession7,
9632
+ cloneCaptureWarnings as cloneCaptureWarnings2,
9633
+ createCaptureSession as createCaptureSession7,
9634
+ initTransparentBackground as initTransparentBackground2,
9635
+ initializeSession as initializeSession7,
9636
+ spawnStreamingEncoder as spawnStreamingEncoder3
9426
9637
  } from "@hyperframes/engine";
9638
+ import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
9427
9639
 
9428
- // src/services/shaderTransitionWorkerPool.ts
9429
- import { Worker } from "node:worker_threads";
9430
- import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
9431
- import { dirname as dirname7, join as join20 } from "node:path";
9432
- import { createRequire } from "node:module";
9433
- import { existsSync as existsSync13 } from "node:fs";
9434
- import { cpus as cpus2 } from "node:os";
9435
- function resolveWorkerEntry(explicit) {
9436
- if (explicit && explicit.length > 0) {
9437
- return { path: explicit, isTs: explicit.endsWith(".ts") };
9640
+ // src/services/hdrImageTransferCache.ts
9641
+ import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
9642
+ var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
9643
+ function createHdrImageTransferCache(options = {}) {
9644
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
9645
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
9646
+ throw new Error(
9647
+ `createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
9648
+ );
9438
9649
  }
9439
- const override = process.env.HF_SHADER_WORKER_ENTRY;
9440
- if (override && override.length > 0) {
9441
- const isTs = override.endsWith(".ts");
9442
- return { path: override, isTs };
9650
+ const entries = /* @__PURE__ */ new Map();
9651
+ let totalBytes = 0;
9652
+ function makeKey(imageId, targetTransfer) {
9653
+ return `${imageId}|${targetTransfer}`;
9443
9654
  }
9444
- const moduleDir = dirname7(fileURLToPath3(import.meta.url));
9445
- const jsPath = join20(moduleDir, "shaderTransitionWorker.js");
9446
- if (existsSync13(jsPath)) return { path: jsPath, isTs: false };
9447
- const tsPath = join20(moduleDir, "shaderTransitionWorker.ts");
9448
- return { path: tsPath, isTs: true };
9449
- }
9450
- function buildExecArgv(entryIsTs) {
9451
- const inherited = [...process.execArgv];
9452
- if (!entryIsTs) return inherited;
9453
- const hasLoader = inherited.some(
9454
- (a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
9455
- );
9456
- if (hasLoader) return inherited;
9457
- try {
9458
- const require2 = createRequire(import.meta.url);
9459
- const tsxEsm = require2.resolve("tsx/esm");
9460
- inherited.push("--import", pathToFileURL(tsxEsm).href);
9461
- } catch {
9655
+ function evictUntilRoom(needed) {
9656
+ while (totalBytes + needed > maxBytes && entries.size > 0) {
9657
+ const lruKey = entries.keys().next().value;
9658
+ if (lruKey === void 0) break;
9659
+ const evicted = entries.get(lruKey);
9660
+ if (evicted) totalBytes -= evicted.byteLength;
9661
+ entries.delete(lruKey);
9662
+ }
9462
9663
  }
9463
- return inherited;
9664
+ return {
9665
+ getConverted(imageId, sourceTransfer, targetTransfer, source) {
9666
+ if (sourceTransfer === targetTransfer) {
9667
+ return source;
9668
+ }
9669
+ if (maxBytes === 0) {
9670
+ const fresh = Buffer.from(source);
9671
+ convertTransfer2(fresh, sourceTransfer, targetTransfer);
9672
+ return fresh;
9673
+ }
9674
+ const key = makeKey(imageId, targetTransfer);
9675
+ const existing = entries.get(key);
9676
+ if (existing) {
9677
+ entries.delete(key);
9678
+ entries.set(key, existing);
9679
+ return existing;
9680
+ }
9681
+ const converted = Buffer.from(source);
9682
+ convertTransfer2(converted, sourceTransfer, targetTransfer);
9683
+ if (converted.byteLength > maxBytes) {
9684
+ return converted;
9685
+ }
9686
+ evictUntilRoom(converted.byteLength);
9687
+ entries.set(key, converted);
9688
+ totalBytes += converted.byteLength;
9689
+ return converted;
9690
+ },
9691
+ size() {
9692
+ return entries.size;
9693
+ },
9694
+ bytesUsed() {
9695
+ return totalBytes;
9696
+ }
9697
+ };
9698
+ }
9699
+
9700
+ // src/services/render/stages/captureHdrSequentialLoop.ts
9701
+ import { join as join20 } from "node:path";
9702
+ import {
9703
+ TRANSITIONS,
9704
+ crossfade
9705
+ } from "@hyperframes/engine";
9706
+ async function runSequentialLayeredFrameLoop(input) {
9707
+ const {
9708
+ job,
9709
+ log,
9710
+ width,
9711
+ height,
9712
+ totalFrames,
9713
+ nativeHdrIds,
9714
+ nativeHdrImageIds,
9715
+ hdrCompositeCtx,
9716
+ hdrPerf,
9717
+ hdrEncoder,
9718
+ domSession,
9719
+ transitionRanges,
9720
+ sceneElements,
9721
+ compositeTransfer,
9722
+ hdrTargetTransfer,
9723
+ hdrVideoEndTimes,
9724
+ cleanedUpVideos,
9725
+ hdrVideoFrameSources,
9726
+ debugDumpEnabled,
9727
+ debugDumpDir,
9728
+ assertNotAborted,
9729
+ onProgress
9730
+ } = input;
9731
+ const beforeCaptureHook = domSession.onBeforeCapture;
9732
+ const bufSize = width * height * 6;
9733
+ const hasTransitions = transitionRanges.length > 0;
9734
+ const transitionBuffers = hasTransitions ? {
9735
+ bufferA: Buffer.alloc(bufSize),
9736
+ bufferB: Buffer.alloc(bufSize),
9737
+ output: Buffer.alloc(bufSize)
9738
+ } : null;
9739
+ const normalCanvas = Buffer.alloc(bufSize);
9740
+ for (let i = 0; i < totalFrames; i++) {
9741
+ assertNotAborted();
9742
+ const time = i * job.config.fps.den / job.config.fps.num;
9743
+ if (hdrPerf) hdrPerf.frames += 1;
9744
+ const stackingInfo = await seekInjectAndQueryStacking(
9745
+ domSession.page,
9746
+ time,
9747
+ beforeCaptureHook,
9748
+ nativeHdrIds,
9749
+ hdrPerf,
9750
+ "frameSeekMs",
9751
+ "frameInjectMs",
9752
+ "stackingQueryMs"
9753
+ );
9754
+ const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
9755
+ if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
9756
+ const hdrEl = stackingInfo.find((e) => e.isHdr);
9757
+ log.debug("[Render] HDR layer composite frame", {
9758
+ frame: i,
9759
+ time: time.toFixed(2),
9760
+ hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
9761
+ stackingCount: stackingInfo.length,
9762
+ activeTransition: activeTransition?.shader
9763
+ });
9764
+ }
9765
+ if (activeTransition && transitionBuffers) {
9766
+ if (hdrPerf) hdrPerf.transitionFrames += 1;
9767
+ const transitionTimingStart = Date.now();
9768
+ const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
9769
+ const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
9770
+ const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
9771
+ timeHdrPhase(hdrPerf, "canvasClearMs", () => {
9772
+ transitionBuffers.bufferA.fill(0);
9773
+ transitionBuffers.bufferB.fill(0);
9774
+ });
9775
+ const sceneCaptures = [
9776
+ [transitionBuffers.bufferA, sceneAIds],
9777
+ [transitionBuffers.bufferB, sceneBIds]
9778
+ ];
9779
+ for (const [sceneBuf, sceneIds] of sceneCaptures) {
9780
+ assertNotAborted();
9781
+ await captureSceneIntoBuffer({
9782
+ session: domSession,
9783
+ sceneBuf,
9784
+ sceneIds,
9785
+ stackingInfo,
9786
+ time,
9787
+ width,
9788
+ height,
9789
+ nativeHdrIds,
9790
+ nativeHdrImageIds,
9791
+ beforeCaptureHook,
9792
+ hdrCompositeCtx,
9793
+ compositeTransfer,
9794
+ hdrTargetTransfer,
9795
+ hdrPerf,
9796
+ log,
9797
+ frameIdx: i
9798
+ });
9799
+ }
9800
+ const shaderName = activeTransition.shader;
9801
+ const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
9802
+ transitionFn(
9803
+ transitionBuffers.bufferA,
9804
+ transitionBuffers.bufferB,
9805
+ transitionBuffers.output,
9806
+ width,
9807
+ height,
9808
+ progress
9809
+ );
9810
+ addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
9811
+ await timeHdrPhaseAsync(
9812
+ hdrPerf,
9813
+ "encoderWriteMs",
9814
+ async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
9815
+ );
9816
+ } else {
9817
+ if (hdrPerf) hdrPerf.normalFrames += 1;
9818
+ timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
9819
+ await timeHdrPhaseAsync(
9820
+ hdrPerf,
9821
+ "normalCompositeMs",
9822
+ () => compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, void 0, i)
9823
+ );
9824
+ if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9825
+ writeFileExclusiveSync(
9826
+ join20(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9827
+ normalCanvas
9828
+ );
9829
+ }
9830
+ await timeHdrPhaseAsync(
9831
+ hdrPerf,
9832
+ "encoderWriteMs",
9833
+ async () => ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i, hdrEncoder)
9834
+ );
9835
+ }
9836
+ cleanupEndedHdrVideos({
9837
+ time,
9838
+ activeTransition,
9839
+ hdrVideoEndTimes,
9840
+ cleanedUpVideos,
9841
+ hdrVideoFrameSources,
9842
+ sceneElements,
9843
+ log
9844
+ });
9845
+ job.framesRendered = i + 1;
9846
+ reportFrameProgress(
9847
+ job,
9848
+ `Layered composite frame ${i + 1}/${job.totalFrames}`,
9849
+ Math.round(25 + (i + 1) / totalFrames * 55),
9850
+ onProgress,
9851
+ i + 1,
9852
+ totalFrames
9853
+ );
9854
+ }
9855
+ }
9856
+
9857
+ // src/services/render/stages/captureHdrHybridLoop.ts
9858
+ import { join as join22 } from "node:path";
9859
+ import {
9860
+ TRANSITIONS as TRANSITIONS2,
9861
+ closeCaptureSession as closeCaptureSession6,
9862
+ createCaptureSession as createCaptureSession6,
9863
+ createFrameReorderBuffer as createFrameReorderBuffer2,
9864
+ crossfade as crossfade2,
9865
+ initTransparentBackground,
9866
+ initializeSession as initializeSession6
9867
+ } from "@hyperframes/engine";
9868
+
9869
+ // src/services/shaderTransitionWorkerPool.ts
9870
+ import { Worker } from "node:worker_threads";
9871
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
9872
+ import { dirname as dirname8, join as join21 } from "node:path";
9873
+ import { createRequire } from "node:module";
9874
+ import { existsSync as existsSync13 } from "node:fs";
9875
+ import { cpus as cpus2 } from "node:os";
9876
+ function resolveWorkerEntry(explicit) {
9877
+ if (explicit && explicit.length > 0) {
9878
+ return { path: explicit, isTs: explicit.endsWith(".ts") };
9879
+ }
9880
+ const override = process.env.HF_SHADER_WORKER_ENTRY;
9881
+ if (override && override.length > 0) {
9882
+ const isTs = override.endsWith(".ts");
9883
+ return { path: override, isTs };
9884
+ }
9885
+ const moduleDir = dirname8(fileURLToPath3(import.meta.url));
9886
+ const jsPath = join21(moduleDir, "shaderTransitionWorker.js");
9887
+ if (existsSync13(jsPath)) return { path: jsPath, isTs: false };
9888
+ const tsPath = join21(moduleDir, "shaderTransitionWorker.ts");
9889
+ return { path: tsPath, isTs: true };
9890
+ }
9891
+ function buildExecArgv(entryIsTs) {
9892
+ const inherited = [...process.execArgv];
9893
+ if (!entryIsTs) return inherited;
9894
+ const hasLoader = inherited.some(
9895
+ (a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
9896
+ );
9897
+ if (hasLoader) return inherited;
9898
+ try {
9899
+ const require2 = createRequire(import.meta.url);
9900
+ const tsxEsm = require2.resolve("tsx/esm");
9901
+ inherited.push("--import", pathToFileURL(tsxEsm).href);
9902
+ } catch {
9903
+ }
9904
+ return inherited;
9464
9905
  }
9465
9906
  async function createShaderTransitionWorkerPool(opts) {
9466
9907
  const cpuCount = Math.max(1, cpus2().length);
@@ -9812,7 +10253,7 @@ async function runHybridLayeredFrameLoop(input) {
9812
10253
  );
9813
10254
  if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9814
10255
  writeFileExclusiveSync(
9815
- join21(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
10256
+ join22(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9816
10257
  canvas
9817
10258
  );
9818
10259
  }
@@ -9998,7 +10439,7 @@ async function runCaptureHdrStage(input) {
9998
10439
  if (hdrVideoFrameSources.has(v.id)) hdrVideoEndTimes.set(v.id, v.end);
9999
10440
  }
10000
10441
  const debugDumpEnabled = process.env.KEEP_TEMP === "1";
10001
- const debugDumpDir = debugDumpEnabled ? join22(framesDir, "debug-composite") : null;
10442
+ const debugDumpDir = debugDumpEnabled ? join23(framesDir, "debug-composite") : null;
10002
10443
  if (debugDumpDir && !existsSync14(debugDumpDir)) {
10003
10444
  mkdirSync7(debugDumpDir, { recursive: true });
10004
10445
  }
@@ -10158,19 +10599,19 @@ import {
10158
10599
  mkdirSync as mkdirSync8,
10159
10600
  readdirSync as readdirSync4,
10160
10601
  readFileSync as readFileSync7,
10161
- rmSync as rmSync7,
10602
+ rmSync as rmSync9,
10162
10603
  statSync as statSync5,
10163
10604
  writeFileSync as writeFileSync4
10164
10605
  } from "node:fs";
10165
10606
  import { readFile as readFile2 } from "node:fs/promises";
10166
- import { dirname as dirname8, join as join24 } from "node:path";
10607
+ import { dirname as dirname9, join as join24 } from "node:path";
10167
10608
  import {
10168
10609
  encodeFramesChunkedConcat,
10169
10610
  encodeFramesFromDir,
10170
10611
  formatFfmpegError,
10171
10612
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10172
10613
  resolveConfig,
10173
- runFfmpeg as runFfmpeg3,
10614
+ runFfmpeg as runFfmpeg5,
10174
10615
  spawnStreamingEncoder as spawnStreamingEncoder4
10175
10616
  } from "@hyperframes/engine";
10176
10617
  import {
@@ -10179,791 +10620,354 @@ import {
10179
10620
  } from "@hyperframes/core";
10180
10621
 
10181
10622
  // src/services/render/stages/gifEncodeArgs.ts
10182
- import { join as join23 } from "node:path";
10623
+ import { frameSequencePath } from "@hyperframes/engine";
10183
10624
  function fpsToFfmpegArg2(fps) {
10184
10625
  return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
10185
10626
  }
10186
- var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
10187
- var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
10188
- var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
10189
- function framesInput(input, fpsArg) {
10190
- return [
10191
- ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
10192
- "-framerate",
10193
- fpsArg,
10194
- ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
10195
- "-i",
10196
- join23(input.framesDir, input.framePattern)
10197
- ];
10198
- }
10199
- function buildGifPalettegenArgs(input) {
10200
- const fpsArg = fpsToFfmpegArg2(input.fps);
10201
- const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
10202
- return [
10203
- "-y",
10204
- ...framesInput(input, fpsArg),
10205
- "-vf",
10206
- `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
10207
- input.palettePath
10208
- ];
10209
- }
10210
- function buildGifPaletteuseArgs(input) {
10211
- const fpsArg = fpsToFfmpegArg2(input.fps);
10212
- const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
10213
- return [
10214
- "-y",
10215
- ...framesInput(input, fpsArg),
10216
- "-i",
10217
- input.palettePath,
10218
- "-lavfi",
10219
- `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
10220
- "-loop",
10221
- String(input.loop),
10222
- ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
10223
- input.outputPath
10224
- ];
10225
- }
10226
-
10227
- // src/services/render/stages/encodeStage.ts
10228
- import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
10229
- function resolveGifLoop(loop) {
10230
- const resolved = loop ?? 0;
10231
- if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
10232
- throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
10233
- }
10234
- return resolved;
10235
- }
10236
- function gifEncodeFailure(startTime, outputPath, error, failureReason) {
10237
- return {
10238
- success: false,
10239
- outputPath,
10240
- durationMs: Date.now() - startTime,
10241
- framesEncoded: 0,
10242
- fileSize: 0,
10243
- error,
10244
- failureReason
10245
- };
10246
- }
10247
- async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
10248
- const { outputPath } = argsInput;
10249
- const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
10250
- if (!result.success) {
10251
- const error = formatFfmpegError(result.exitCode, result.stderr);
10252
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10253
- }
10254
- const gif = readFileSync7(outputPath);
10255
- if (!clearGifFramesBeforeNext(gif)) {
10256
- const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
10257
- return gifEncodeFailure(startTime, outputPath, error);
10258
- }
10259
- writeFileSync4(outputPath, gif);
10260
- return null;
10261
- }
10262
- async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
10263
- const startTime = Date.now();
10264
- const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
10265
- const frameCount = files.length;
10266
- if (frameCount === 0) {
10267
- return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
10268
- }
10269
- const argsInput = {
10270
- framesDir,
10271
- framePattern,
10272
- palettePath: input.palettePath,
10273
- outputPath,
10274
- fps: input.fps,
10275
- loop: input.loop,
10276
- preserveAlpha: input.preserveAlpha
10277
- };
10278
- try {
10279
- const run = { signal: input.signal, timeout: input.timeout };
10280
- for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
10281
- const result = await runFfmpeg3(args, run);
10282
- if (!result.success) {
10283
- const error = formatFfmpegError(result.exitCode, result.stderr);
10284
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10285
- }
10286
- }
10287
- if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
10288
- const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
10289
- if (failure) return failure;
10290
- }
10291
- const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
10292
- return {
10293
- success: true,
10294
- outputPath,
10295
- durationMs: Date.now() - startTime,
10296
- framesEncoded: frameCount,
10297
- fileSize
10298
- };
10299
- } finally {
10300
- rmSync7(input.palettePath, { force: true });
10301
- }
10302
- }
10303
- function startEncodeProgress(job, stage, onProgress) {
10304
- const total = job.totalFrames ?? 0;
10305
- updateJobStatus(job, "encoding", stage, 75, onProgress, {
10306
- code: "encode",
10307
- ...total > 0 && { done: 0, total }
10308
- });
10309
- return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10310
- }
10311
- function resolveEngineConfig(input) {
10312
- return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10313
- }
10314
- function videoEncoderOptions(input, engineCfg) {
10315
- const { job, preset } = input;
10316
- return {
10317
- fps: job.config.fps,
10318
- width: input.width,
10319
- height: input.height,
10320
- codec: preset.codec,
10321
- preset: preset.preset,
10322
- quality: input.effectiveQuality,
10323
- bitrate: input.effectiveBitrate,
10324
- pixelFormat: preset.pixelFormat,
10325
- vp9CpuUsed: engineCfg.vp9CpuUsed,
10326
- useGpu: job.config.useGpu,
10327
- hdr: preset.hdr,
10328
- // Distributed chunk renders pass these so the encoder writes closed-GOP
10329
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
10330
- // renders leave both undefined → preserves the existing open-GOP output.
10331
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10332
- gopSize: input.gopSize
10333
- };
10334
- }
10335
- function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10336
- return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
10337
- }
10338
- async function readLandedFrame(workerPath, mergedPath) {
10339
- if (workerPath !== void 0) {
10340
- try {
10341
- return await readFile2(workerPath);
10342
- } catch (error) {
10343
- if (error.code !== "ENOENT") throw error;
10344
- }
10345
- }
10346
- return readFile2(mergedPath);
10347
- }
10348
- function encodeWhileCapturing(input, totalFrames) {
10349
- const engineCfg = resolveEngineConfig(input);
10350
- const stop = new AbortController();
10351
- const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10352
- const landed = /* @__PURE__ */ new Map();
10353
- let captureDone = false;
10354
- let framesFed = 0;
10355
- let wake = null;
10356
- const nudge = () => {
10357
- wake?.();
10358
- wake = null;
10359
- };
10360
- signal.addEventListener("abort", nudge, { once: true });
10361
- let reportFed = null;
10362
- const encoder = spawnStreamingEncoder4(
10363
- input.videoOnlyPath,
10364
- { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10365
- signal,
10366
- engineCfg
10367
- );
10368
- const fed = (async () => {
10369
- const stream = await encoder;
10370
- for (let next = 0; next < totalFrames; next++) {
10371
- while (!landed.has(next) && !captureDone && !signal.aborted) {
10372
- await new Promise((resolve12) => wake = resolve12);
10373
- }
10374
- if (signal.aborted) return;
10375
- const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10376
- const frame = await readLandedFrame(landed.get(next), mergedPath);
10377
- landed.delete(next);
10378
- if (!await stream.writeFrame(frame)) {
10379
- throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10380
- }
10381
- framesFed++;
10382
- reportFed?.(framesFed);
10383
- }
10384
- })().then(
10385
- () => void 0,
10386
- (error) => error ?? new Error("encode failed")
10387
- );
10388
- const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10389
- return {
10390
- onFrameLanded: (frame) => {
10391
- landed.set(frame.index, frame.path);
10392
- nudge();
10393
- },
10394
- finish: async () => {
10395
- const start = Date.now();
10396
- captureDone = true;
10397
- input.log.info("[Render] Encoded frames while capturing", {
10398
- framesFed,
10399
- totalFrames
10400
- });
10401
- const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10402
- reportFed = encoded;
10403
- nudge();
10404
- const failure = await fed;
10405
- const result = await closeEncoder();
10406
- input.assertNotAborted();
10407
- if (failure === void 0 && result?.success) {
10408
- encoded();
10409
- return { encodeMs: Date.now() - start };
10410
- }
10411
- input.log.warn(
10412
- "[Render] Encoding while capturing failed; encoding the frames after capture",
10413
- {
10414
- error: String(failure ?? result?.error)
10415
- }
10416
- );
10417
- return runEncodeStage(input);
10418
- },
10419
- abort: async () => {
10420
- stop.abort();
10421
- await fed;
10422
- await closeEncoder();
10423
- },
10424
- get framesFed() {
10425
- return framesFed;
10426
- }
10427
- };
10428
- }
10429
- async function runEncodeStage(input) {
10430
- const {
10431
- job,
10432
- log,
10433
- outputPath,
10434
- framesDir,
10435
- videoOnlyPath,
10436
- needsAlpha,
10437
- hasAudio,
10438
- audioOutputPath,
10439
- isPngSequence,
10440
- isGif,
10441
- enableChunkedEncode,
10442
- chunkedEncodeSize,
10443
- abortSignal,
10444
- assertNotAborted,
10445
- onProgress
10446
- } = input;
10447
- const stage5Start = Date.now();
10448
- if (isPngSequence) {
10449
- const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
10450
- if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
10451
- const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
10452
- if (captured.length === 0) {
10453
- throw new Error(
10454
- `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
10455
- );
10456
- }
10457
- captured.forEach((name, i) => {
10458
- const dst = join24(outputPath, formatExportFrameName(i, "png"));
10459
- copyFileSync3(join24(framesDir, name), dst);
10460
- });
10461
- if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
10462
- copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
10463
- log.info(
10464
- `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
10465
- );
10466
- }
10467
- encoded();
10468
- return { encodeMs: Date.now() - stage5Start };
10469
- }
10470
- const engineCfg = resolveEngineConfig(input);
10471
- if (isGif) {
10472
- const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10473
- if (hasAudio) {
10474
- log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
10475
- }
10476
- const frameExt2 = frameFileExtension2(input.captureImageFormat);
10477
- const framePattern2 = `frame_%06d.${frameExt2}`;
10478
- const loop = resolveGifLoop(job.config.gifLoop);
10479
- const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
10480
- fps: job.config.fps,
10481
- loop,
10482
- palettePath: join24(dirname8(videoOnlyPath), "gif-palette.png"),
10483
- preserveAlpha: needsAlpha,
10484
- signal: abortSignal,
10485
- timeout: engineCfg.ffmpegEncodeTimeout
10486
- });
10487
- assertNotAborted();
10488
- if (!encodeResult2.success) {
10489
- throw encoderFailureError("Encoding failed", encodeResult2);
10490
- }
10491
- encoded();
10492
- return { encodeMs: Date.now() - stage5Start };
10493
- }
10494
- const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
10495
- const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
10496
- const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
10497
- const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10498
- const frameExt = frameFileExtension2(input.captureImageFormat);
10499
- const framePattern = `frame_%06d.${frameExt}`;
10500
- const encoderOpts = videoEncoderOptions(input, engineCfg);
10501
- const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10502
- framesDir,
10503
- framePattern,
10504
- videoOnlyPath,
10505
- encoderOpts,
10506
- chunkedEncodeSize,
10507
- abortSignal,
10508
- videoEngineCfg,
10509
- onFramesEncoded
10510
- ) : await encodeFramesFromDir(
10511
- framesDir,
10512
- framePattern,
10513
- videoOnlyPath,
10514
- encoderOpts,
10515
- abortSignal,
10516
- videoEngineCfg,
10517
- onFramesEncoded
10518
- );
10519
- assertNotAborted();
10520
- if (!encodeResult.success) {
10521
- throw encoderFailureError("Encoding failed", encodeResult);
10522
- }
10523
- onFramesEncoded();
10524
- return { encodeMs: Date.now() - stage5Start };
10525
- }
10526
-
10527
- // src/services/render/stages/assembleStage.ts
10528
- import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
10529
- import { extname as extname3 } from "node:path";
10530
-
10531
- // src/services/render/audioPadTrim.ts
10532
- import { spawn } from "node:child_process";
10533
- import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync8 } from "node:fs";
10534
- import { dirname as dirname9, join as join25 } from "node:path";
10535
- import {
10536
- DEFAULT_CONFIG,
10537
- describeFfmpegFailure as describeFfmpegFailure2,
10538
- encodeAac,
10539
- extractAudioMetadata as extractAudioMetadata3,
10540
- getFfprobeBinary,
10541
- ManagedChildProcess,
10542
- runFfmpeg as runFfmpeg5,
10543
- trackChildProcess
10544
- } from "@hyperframes/engine";
10545
- import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
10546
-
10547
- // src/services/render/audioTruePeak.ts
10548
- import {
10549
- aacSegmentCount,
10550
- describeFfmpegFailure,
10551
- extractAudioMetadata as extractAudioMetadata2,
10552
- runFfmpeg as runFfmpeg4
10553
- } from "@hyperframes/engine";
10554
- var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
10555
- function checkedDbfs(value) {
10556
- if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
10557
- throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
10558
- }
10559
- return value;
10560
- }
10561
- async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
10562
- let firstBlock;
10563
- let laterBlocks = Number.NEGATIVE_INFINITY;
10564
- let partialLine = "";
10565
- const readLines = (chunk) => {
10566
- const lines = (partialLine + chunk).split(/\r|\n/);
10567
- partialLine = lines.pop() ?? "";
10568
- for (const line of lines) {
10569
- const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
10570
- if (!ftpk) continue;
10571
- const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
10572
- if (firstBlock === void 0) firstBlock = block;
10573
- else laterBlocks = Math.max(laterBlocks, block);
10574
- }
10575
- };
10576
- const result = await runFfmpeg4(
10577
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
10578
- { signal, timeout: timeoutMs, onStderr: readLines }
10579
- );
10580
- if (!result.success) {
10581
- throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
10582
- }
10583
- const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
10584
- return {
10585
- peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
10586
- firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
10587
- laterBlocks: checkedDbfs(laterBlocks)
10588
- };
10589
- }
10590
- async function measureTruePeakDbfs(audioPath, options) {
10591
- const { timeoutMs, signal } = options;
10592
- const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
10593
- const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
10594
- const block = 4800;
10595
- const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
10596
- if (count === 1) return wholeFile();
10597
- const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
10598
- const peers = new AbortController();
10599
- const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
10600
- const scans = await Promise.all(
10601
- Array.from({ length: count }, (_, k) => {
10602
- const start = k === 0 ? 0 : cut(k) - block;
10603
- const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
10604
- return scanTruePeak(
10605
- audioPath,
10606
- `atrim=start_sample=${start}${end},`,
10607
- timeoutMs,
10608
- segmentSignal
10609
- );
10610
- })
10611
- ).catch((error) => {
10612
- peers.abort();
10613
- throw error;
10614
- });
10615
- const last = scans[count - 1];
10616
- const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
10617
- if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
10618
- return wholeFile();
10619
- }
10620
-
10621
- // src/services/render/audioPadTrim.ts
10622
- var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
10623
- var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
10624
- var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
10625
- var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
10626
- function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
10627
- return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
10628
- }
10629
- function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
10630
- const delta = targetDurationSeconds - sourceDurationSeconds;
10631
- const targetSec = formatSeconds(targetDurationSeconds);
10632
- if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
10633
- return {
10634
- operation: "copy",
10635
- steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
10636
- cleanupPaths: []
10637
- };
10638
- }
10639
- if (delta > 0) {
10640
- return {
10641
- operation: "pad",
10642
- steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
10643
- cleanupPaths: []
10644
- };
10645
- }
10646
- return {
10647
- operation: "trim",
10648
- steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
10649
- cleanupPaths: []
10650
- };
10651
- }
10652
- function formatSeconds(sec) {
10653
- return sec.toFixed(6);
10654
- }
10655
- function sanitizeProbeFailure(reason, paths) {
10656
- const message = reason instanceof Error ? reason.message : String(reason);
10657
- const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
10658
- return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
10659
- }
10660
- async function padOrTrimAudioToVideoFrameCount(input) {
10661
- const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
10662
- const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
10663
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
10664
- const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
10665
- const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
10666
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
10667
- const [videoResult, audioResult] = await Promise.allSettled([
10668
- probeVideo(input.videoPath),
10669
- probeAudio(input.audioPath, input.signal)
10670
- ]);
10671
- const probePaths = [input.videoPath, input.audioPath, input.outputPath];
10672
- if (videoResult.status === "rejected") {
10673
- return failResult(
10674
- input.outputPath,
10675
- 0,
10676
- audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
10677
- `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
10678
- videoResult.reason,
10679
- probePaths
10680
- )}`
10681
- );
10682
- }
10683
- if (audioResult.status === "rejected") {
10684
- return failResult(
10685
- input.outputPath,
10686
- 0,
10687
- 0,
10688
- `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
10689
- audioResult.reason,
10690
- probePaths
10691
- )}`
10692
- );
10693
- }
10694
- const videoInfo = videoResult.value;
10695
- const audioInfo = audioResult.value;
10696
- if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
10697
- return failResult(
10698
- input.outputPath,
10699
- 0,
10700
- audioInfo.durationSeconds,
10701
- `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
10702
- );
10703
- }
10704
- const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
10705
- const plan2 = buildPadTrimAudioPlan(
10706
- input.audioPath,
10707
- input.outputPath,
10708
- audioInfo.durationSeconds,
10709
- targetDurationSeconds
10710
- );
10711
- try {
10712
- for (const step of plan2.steps) {
10713
- const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
10714
- if (!ffmpegResult.success) {
10715
- return {
10716
- success: false,
10717
- outputPath: input.outputPath,
10718
- targetDurationSeconds,
10719
- sourceDurationSeconds: audioInfo.durationSeconds,
10720
- operation: plan2.operation,
10721
- error: ffmpegResult.error,
10722
- failureReason: ffmpegResult.failureReason
10723
- };
10724
- }
10725
- }
10726
- } catch (err) {
10727
- return {
10728
- success: false,
10729
- outputPath: input.outputPath,
10730
- targetDurationSeconds,
10731
- sourceDurationSeconds: audioInfo.durationSeconds,
10732
- operation: plan2.operation,
10733
- error: `audioPadTrim: failed to materialize ${plan2.operation}: ${err instanceof Error ? err.message : String(err)}`
10734
- };
10735
- } finally {
10736
- for (const path of plan2.cleanupPaths) rmSync8(path, { force: true });
10737
- }
10738
- let loweredDb = 0;
10739
- if (probeTruePeak) {
10740
- const correction = await enforceAacTruePeak({
10741
- audioPath: input.outputPath,
10742
- targetDurationSeconds,
10743
- signal: input.signal,
10744
- probeTruePeak,
10745
- encode
10746
- });
10747
- if (!correction.success) {
10748
- return {
10749
- success: false,
10750
- outputPath: input.outputPath,
10751
- targetDurationSeconds,
10752
- sourceDurationSeconds: audioInfo.durationSeconds,
10753
- operation: plan2.operation,
10754
- error: correction.error,
10755
- failureReason: correction.failureReason
10756
- };
10757
- }
10758
- loweredDb = correction.loweredDb ?? 0;
10627
+ var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
10628
+ var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
10629
+ var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
10630
+ function framesInput(input, fpsArg) {
10631
+ return [
10632
+ ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
10633
+ "-framerate",
10634
+ fpsArg,
10635
+ ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
10636
+ "-i",
10637
+ frameSequencePath(input.framesDir, input.framePattern)
10638
+ ];
10639
+ }
10640
+ function buildGifPalettegenArgs(input) {
10641
+ const fpsArg = fpsToFfmpegArg2(input.fps);
10642
+ const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
10643
+ return [
10644
+ "-y",
10645
+ ...framesInput(input, fpsArg),
10646
+ "-vf",
10647
+ `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
10648
+ input.palettePath
10649
+ ];
10650
+ }
10651
+ function buildGifPaletteuseArgs(input) {
10652
+ const fpsArg = fpsToFfmpegArg2(input.fps);
10653
+ const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
10654
+ return [
10655
+ "-y",
10656
+ ...framesInput(input, fpsArg),
10657
+ "-i",
10658
+ input.palettePath,
10659
+ "-lavfi",
10660
+ `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
10661
+ "-loop",
10662
+ String(input.loop),
10663
+ ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
10664
+ input.outputPath
10665
+ ];
10666
+ }
10667
+
10668
+ // src/services/render/stages/encodeStage.ts
10669
+ import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
10670
+ function resolveGifLoop(loop) {
10671
+ const resolved = loop ?? 0;
10672
+ if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
10673
+ throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
10759
10674
  }
10675
+ return resolved;
10676
+ }
10677
+ function gifEncodeFailure(startTime, outputPath, error, failureReason) {
10760
10678
  return {
10761
- success: true,
10762
- outputPath: input.outputPath,
10763
- targetDurationSeconds,
10764
- sourceDurationSeconds: audioInfo.durationSeconds,
10765
- operation: plan2.operation,
10766
- ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
10679
+ success: false,
10680
+ outputPath,
10681
+ durationMs: Date.now() - startTime,
10682
+ framesEncoded: 0,
10683
+ fileSize: 0,
10684
+ error,
10685
+ failureReason
10767
10686
  };
10768
10687
  }
10769
- async function constrainAacTruePeak(input) {
10770
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
10771
- return enforceAacTruePeak({
10772
- audioPath: input.audioPath,
10773
- targetDurationSeconds: input.targetDurationSeconds,
10774
- signal: input.signal,
10775
- probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
10776
- encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
10777
- });
10688
+ async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
10689
+ const { outputPath } = argsInput;
10690
+ const result = await runFfmpeg5(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
10691
+ if (!result.success) {
10692
+ const error = formatFfmpegError(result.exitCode, result.stderr);
10693
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10694
+ }
10695
+ const gif = readFileSync7(outputPath);
10696
+ if (!clearGifFramesBeforeNext(gif)) {
10697
+ const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
10698
+ return gifEncodeFailure(startTime, outputPath, error);
10699
+ }
10700
+ writeFileSync4(outputPath, gif);
10701
+ return null;
10778
10702
  }
10779
- async function enforceAacTruePeak(input) {
10780
- let scratchDir;
10703
+ async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
10704
+ const startTime = Date.now();
10705
+ const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
10706
+ const frameCount = files.length;
10707
+ if (frameCount === 0) {
10708
+ return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
10709
+ }
10710
+ const argsInput = {
10711
+ framesDir,
10712
+ framePattern,
10713
+ palettePath: input.palettePath,
10714
+ outputPath,
10715
+ fps: input.fps,
10716
+ loop: input.loop,
10717
+ preserveAlpha: input.preserveAlpha
10718
+ };
10781
10719
  try {
10782
- scratchDir = mkdtempSync3(join25(dirname9(input.audioPath), ".true-peak-"));
10783
- const correctedPath = join25(scratchDir, "audio.m4a");
10784
- const measurements = [];
10785
- let attenuationDb = 0;
10786
- let measuredPath = input.audioPath;
10787
- for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
10788
- const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
10789
- measurements.push(
10790
- `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
10791
- );
10792
- if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
10793
- return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
10794
- }
10795
- if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
10796
- if (measuredPath !== correctedPath) return { success: true };
10797
- renameSync4(correctedPath, input.audioPath);
10798
- return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
10799
- }
10800
- if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
10801
- const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
10802
- attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
10803
- const result = await input.encode(
10804
- input.audioPath,
10805
- correctedPath,
10806
- input.targetDurationSeconds,
10807
- aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
10808
- );
10720
+ const run = { signal: input.signal, timeout: input.timeout };
10721
+ for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
10722
+ const result = await runFfmpeg5(args, run);
10809
10723
  if (!result.success) {
10810
- return {
10811
- success: false,
10812
- error: sanitizeProbeFailure(
10813
- result.error ?? "audioPadTrim: failed to constrain AAC true peak",
10814
- [input.audioPath, correctedPath]
10815
- ),
10816
- failureReason: result.failureReason
10817
- };
10724
+ const error = formatFfmpegError(result.exitCode, result.stderr);
10725
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10818
10726
  }
10819
- measuredPath = correctedPath;
10820
10727
  }
10728
+ if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
10729
+ const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
10730
+ if (failure) return failure;
10731
+ }
10732
+ const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
10821
10733
  return {
10822
- success: false,
10823
- error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
10824
- };
10825
- } catch (err) {
10826
- return {
10827
- success: false,
10828
- error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
10829
- err,
10830
- scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
10831
- )}`
10734
+ success: true,
10735
+ outputPath,
10736
+ durationMs: Date.now() - startTime,
10737
+ framesEncoded: frameCount,
10738
+ fileSize
10832
10739
  };
10833
10740
  } finally {
10834
- if (scratchDir) rmSync8(scratchDir, { recursive: true, force: true });
10741
+ rmSync9(input.palettePath, { force: true });
10835
10742
  }
10836
10743
  }
10837
- function failResult(outputPath, target, source, error) {
10838
- return {
10839
- success: false,
10840
- outputPath,
10841
- targetDurationSeconds: target,
10842
- sourceDurationSeconds: source,
10843
- operation: "copy",
10844
- error
10845
- };
10846
- }
10847
- async function defaultProbeVideoFrameInfo(videoPath, signal) {
10848
- const fastInfo = await runFfprobeJson(
10849
- [
10850
- "-v",
10851
- "error",
10852
- "-select_streams",
10853
- "v:0",
10854
- "-show_entries",
10855
- "stream=nb_frames,r_frame_rate",
10856
- "-of",
10857
- "json",
10858
- "--",
10859
- videoPath
10860
- ],
10861
- signal
10862
- );
10863
- let stream = fastInfo.streams?.[0];
10864
- const fastCount = Number(stream?.nb_frames);
10865
- if (stream && Number.isFinite(fastCount) && fastCount > 0) {
10866
- return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
10867
- }
10868
- const slowInfo = await runFfprobeJson(
10869
- [
10870
- "-v",
10871
- "error",
10872
- "-select_streams",
10873
- "v:0",
10874
- "-count_packets",
10875
- "-show_entries",
10876
- "stream=nb_read_packets,r_frame_rate",
10877
- "-of",
10878
- "json",
10879
- "--",
10880
- videoPath
10881
- ],
10882
- signal
10883
- );
10884
- stream = slowInfo.streams?.[0];
10885
- if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
10886
- const slowCount = Number(stream.nb_read_packets);
10887
- if (!Number.isFinite(slowCount) || slowCount <= 0) {
10888
- throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
10889
- }
10890
- return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
10744
+ function startEncodeProgress(job, stage, onProgress) {
10745
+ const total = job.totalFrames ?? 0;
10746
+ updateJobStatus(job, "encoding", stage, 75, onProgress, {
10747
+ code: "encode",
10748
+ ...total > 0 && { done: 0, total }
10749
+ });
10750
+ return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10891
10751
  }
10892
- function parseFrameRate(rate) {
10893
- const [n, d] = rate.split("/");
10894
- const fpsNum = Number(n);
10895
- const fpsDen = d === void 0 ? 1 : Number(d);
10896
- if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
10897
- throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
10898
- }
10899
- return { fpsNum, fpsDen };
10752
+ function resolveEngineConfig(input) {
10753
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10900
10754
  }
10901
- async function defaultProbeAudioInfo(audioPath, signal) {
10902
- const metadata = await extractAudioMetadata3(audioPath, { signal });
10755
+ function videoEncoderOptions(input, engineCfg) {
10756
+ const { job, preset } = input;
10903
10757
  return {
10904
- durationSeconds: metadata.durationSeconds,
10905
- sampleRate: metadata.sampleRate,
10906
- channels: metadata.channels,
10907
- audioCodec: metadata.audioCodec
10758
+ fps: job.config.fps,
10759
+ width: input.width,
10760
+ height: input.height,
10761
+ codec: preset.codec,
10762
+ preset: preset.preset,
10763
+ quality: input.effectiveQuality,
10764
+ bitrate: input.effectiveBitrate,
10765
+ pixelFormat: preset.pixelFormat,
10766
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
10767
+ useGpu: job.config.useGpu,
10768
+ hdr: preset.hdr,
10769
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
10770
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
10771
+ // renders leave both undefined → preserves the existing open-GOP output.
10772
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10773
+ gopSize: input.gopSize
10908
10774
  };
10909
10775
  }
10910
- async function defaultRunFfmpeg(args, timeoutMs, signal) {
10911
- return toStepResult(await runFfmpeg5(args, { signal, timeout: timeoutMs }), timeoutMs);
10776
+ function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10777
+ return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
10778
+ }
10779
+ async function readLandedFrame(workerPath, mergedPath) {
10780
+ if (workerPath !== void 0) {
10781
+ try {
10782
+ return await readFile2(workerPath);
10783
+ } catch (error) {
10784
+ if (error.code !== "ENOENT") throw error;
10785
+ }
10786
+ }
10787
+ return readFile2(mergedPath);
10912
10788
  }
10913
- function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
10914
- return async (inputPath, outputPath, durationSeconds, filter) => {
10915
- const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
10916
- return toStepResult(
10917
- await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
10918
- timeoutMs
10919
- );
10789
+ function encodeWhileCapturing(input, totalFrames) {
10790
+ const engineCfg = resolveEngineConfig(input);
10791
+ const stop = new AbortController();
10792
+ const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10793
+ const landed = /* @__PURE__ */ new Map();
10794
+ let captureDone = false;
10795
+ let framesFed = 0;
10796
+ let wake = null;
10797
+ const nudge = () => {
10798
+ wake?.();
10799
+ wake = null;
10920
10800
  };
10921
- }
10922
- function toStepResult(result, timeoutMs) {
10923
- if (result.success) return { success: true };
10801
+ signal.addEventListener("abort", nudge, { once: true });
10802
+ let reportFed = null;
10803
+ const encoder = spawnStreamingEncoder4(
10804
+ input.videoOnlyPath,
10805
+ { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10806
+ signal,
10807
+ engineCfg
10808
+ );
10809
+ const fed = (async () => {
10810
+ const stream = await encoder;
10811
+ for (let next = 0; next < totalFrames; next++) {
10812
+ while (!landed.has(next) && !captureDone && !signal.aborted) {
10813
+ await new Promise((resolve12) => wake = resolve12);
10814
+ }
10815
+ if (signal.aborted) return;
10816
+ const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10817
+ const frame = await readLandedFrame(landed.get(next), mergedPath);
10818
+ landed.delete(next);
10819
+ if (!await stream.writeFrame(frame)) {
10820
+ throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10821
+ }
10822
+ framesFed++;
10823
+ reportFed?.(framesFed);
10824
+ }
10825
+ })().then(
10826
+ () => void 0,
10827
+ (error) => error ?? new Error("encode failed")
10828
+ );
10829
+ const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10924
10830
  return {
10925
- success: false,
10926
- error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
10927
- failureReason: result.failureReason
10831
+ onFrameLanded: (frame) => {
10832
+ landed.set(frame.index, frame.path);
10833
+ nudge();
10834
+ },
10835
+ finish: async () => {
10836
+ const start = Date.now();
10837
+ captureDone = true;
10838
+ input.log.info("[Render] Encoded frames while capturing", {
10839
+ framesFed,
10840
+ totalFrames
10841
+ });
10842
+ const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10843
+ reportFed = encoded;
10844
+ nudge();
10845
+ const failure = await fed;
10846
+ const result = await closeEncoder();
10847
+ input.assertNotAborted();
10848
+ if (failure === void 0 && result?.success) {
10849
+ encoded();
10850
+ return { encodeMs: Date.now() - start };
10851
+ }
10852
+ input.log.warn(
10853
+ "[Render] Encoding while capturing failed; encoding the frames after capture",
10854
+ {
10855
+ error: String(failure ?? result?.error)
10856
+ }
10857
+ );
10858
+ return runEncodeStage(input);
10859
+ },
10860
+ abort: async () => {
10861
+ stop.abort();
10862
+ await fed;
10863
+ await closeEncoder();
10864
+ },
10865
+ get framesFed() {
10866
+ return framesFed;
10867
+ }
10928
10868
  };
10929
10869
  }
10930
- async function runFfprobeJson(args, signal) {
10931
- if (!args.includes("--")) {
10932
- throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
10933
- }
10934
- const proc = spawn(getFfprobeBinary(), args, {
10935
- stdio: ["ignore", "pipe", "pipe"],
10936
- windowsHide: true
10937
- });
10938
- trackChildProcess(proc);
10939
- let stdout = "";
10940
- proc.stdout.on("data", (data) => {
10941
- stdout += data.toString();
10942
- });
10943
- const managed = new ManagedChildProcess(proc, {
10944
- signal,
10945
- deadlineAtMs: Date.now() + 3e4
10946
- });
10947
- const outcome = await managed.wait();
10948
- if (outcome.reason === "spawn_error") {
10949
- if (outcome.error?.code === "ENOENT") {
10950
- throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
10870
+ async function runEncodeStage(input) {
10871
+ const {
10872
+ job,
10873
+ log,
10874
+ outputPath,
10875
+ framesDir,
10876
+ videoOnlyPath,
10877
+ needsAlpha,
10878
+ hasAudio,
10879
+ audioOutputPath,
10880
+ isPngSequence,
10881
+ isGif,
10882
+ enableChunkedEncode,
10883
+ chunkedEncodeSize,
10884
+ abortSignal,
10885
+ assertNotAborted,
10886
+ onProgress
10887
+ } = input;
10888
+ const stage5Start = Date.now();
10889
+ if (isPngSequence) {
10890
+ const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
10891
+ if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
10892
+ const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
10893
+ if (captured.length === 0) {
10894
+ throw new Error(
10895
+ `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
10896
+ );
10951
10897
  }
10952
- throw outcome.error ?? new Error(outcome.stderr);
10898
+ captured.forEach((name, i) => {
10899
+ const dst = join24(outputPath, formatExportFrameName(i, "png"));
10900
+ copyFileSync3(join24(framesDir, name), dst);
10901
+ });
10902
+ if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
10903
+ copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
10904
+ log.info(
10905
+ `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
10906
+ );
10907
+ }
10908
+ encoded();
10909
+ return { encodeMs: Date.now() - stage5Start };
10953
10910
  }
10954
- if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
10955
- const probed = args[args.length - 1];
10956
- const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
10957
- throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
10911
+ const engineCfg = resolveEngineConfig(input);
10912
+ if (isGif) {
10913
+ const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10914
+ if (hasAudio) {
10915
+ log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
10916
+ }
10917
+ const frameExt2 = frameFileExtension2(input.captureImageFormat);
10918
+ const framePattern2 = `frame_%06d.${frameExt2}`;
10919
+ const loop = resolveGifLoop(job.config.gifLoop);
10920
+ const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
10921
+ fps: job.config.fps,
10922
+ loop,
10923
+ palettePath: join24(dirname9(videoOnlyPath), "gif-palette.png"),
10924
+ preserveAlpha: needsAlpha,
10925
+ signal: abortSignal,
10926
+ timeout: engineCfg.ffmpegEncodeTimeout
10927
+ });
10928
+ assertNotAborted();
10929
+ if (!encodeResult2.success) {
10930
+ throw encoderFailureError("Encoding failed", encodeResult2);
10931
+ }
10932
+ encoded();
10933
+ return { encodeMs: Date.now() - stage5Start };
10958
10934
  }
10959
- try {
10960
- return JSON.parse(stdout);
10961
- } catch (err) {
10962
- throw new Error(`Failed to parse ffprobe output: ${err.message}`);
10935
+ const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
10936
+ const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
10937
+ const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
10938
+ const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10939
+ const frameExt = frameFileExtension2(input.captureImageFormat);
10940
+ const framePattern = `frame_%06d.${frameExt}`;
10941
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
10942
+ const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10943
+ framesDir,
10944
+ framePattern,
10945
+ videoOnlyPath,
10946
+ encoderOpts,
10947
+ chunkedEncodeSize,
10948
+ abortSignal,
10949
+ videoEngineCfg,
10950
+ onFramesEncoded
10951
+ ) : await encodeFramesFromDir(
10952
+ framesDir,
10953
+ framePattern,
10954
+ videoOnlyPath,
10955
+ encoderOpts,
10956
+ abortSignal,
10957
+ videoEngineCfg,
10958
+ onFramesEncoded
10959
+ );
10960
+ assertNotAborted();
10961
+ if (!encodeResult.success) {
10962
+ throw encoderFailureError("Encoding failed", encodeResult);
10963
10963
  }
10964
+ onFramesEncoded();
10965
+ return { encodeMs: Date.now() - stage5Start };
10964
10966
  }
10965
10967
 
10966
10968
  // src/services/render/stages/assembleStage.ts
10969
+ import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
10970
+ import { extname as extname3 } from "node:path";
10967
10971
  function recordLimiterAttenuation(job, audioLoweredDb) {
10968
10972
  if (audioLoweredDb === void 0) return;
10969
10973
  job.audioLoweredDb = audioLoweredDb;
@@ -11129,7 +11133,7 @@ function sampleDirectoryBytes(dir) {
11129
11133
  continue;
11130
11134
  }
11131
11135
  for (const name of entries) {
11132
- const full = join26(current, name);
11136
+ const full = join25(current, name);
11133
11137
  try {
11134
11138
  const st = statSync6(full);
11135
11139
  if (st.isDirectory()) {
@@ -11277,7 +11281,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
11277
11281
  const ranges = [];
11278
11282
  let rangeStart = null;
11279
11283
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
11280
- const framePath = join26(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11284
+ const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11281
11285
  const missing = !existsSync16(framePath) || statSync6(framePath).size <= 8;
11282
11286
  if (missing && rangeStart === null) {
11283
11287
  rangeStart = frameIndex;
@@ -11300,7 +11304,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
11300
11304
  workerId,
11301
11305
  startFrame: rangeStart + range.startFrame,
11302
11306
  endFrame: rangeStart + range.endFrame,
11303
- outputDir: join26(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
11307
+ outputDir: join25(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
11304
11308
  outputFrameOffset: rangeStart
11305
11309
  }));
11306
11310
  batches.push(batch);
@@ -11328,8 +11332,8 @@ function getNextRetryWorkerCount(currentWorkers) {
11328
11332
  return Math.max(1, Math.floor(currentWorkers / 2));
11329
11333
  }
11330
11334
  function resolveRenderWorkDirPrefix(outputPath, jobId, platform2 = process.platform, systemTempDir = tmpdir2()) {
11331
- if (platform2 === "win32" || platform2 === "darwin") return join26(systemTempDir, "hf-render-");
11332
- return join26(dirname10(outputPath), `work-${jobId}-`);
11335
+ if (platform2 === "win32" || platform2 === "darwin") return join25(systemTempDir, "hf-render-");
11336
+ return join25(dirname10(outputPath), `work-${jobId}-`);
11333
11337
  }
11334
11338
  var MAX_TRANSIENT_CAPTURE_RETRIES = 1;
11335
11339
  function isTransientCaptureRetryEligible(failureKind, missing, retriesUsed) {
@@ -11361,7 +11365,7 @@ The composition is too large for the available memory. To reduce memory pressure
11361
11365
  function countCapturedFrames(totalFrames, framesDir, frameExt) {
11362
11366
  let captured = 0;
11363
11367
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
11364
- const framePath = join26(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11368
+ const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11365
11369
  if (existsSync16(framePath)) captured++;
11366
11370
  }
11367
11371
  return captured;
@@ -11387,7 +11391,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
11387
11391
  reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
11388
11392
  });
11389
11393
  pendingTransientRetry = false;
11390
- const attemptWorkDir = join26(options.workDir, `capture-attempt-${attempt}`);
11394
+ const attemptWorkDir = join25(options.workDir, `capture-attempt-${attempt}`);
11391
11395
  const batches = missingRanges ? buildMissingFrameRetryBatches(
11392
11396
  missingRanges,
11393
11397
  currentWorkers,
@@ -11956,17 +11960,17 @@ function deVerifyFallbackTelemetry(err) {
11956
11960
  }
11957
11961
  function resolveRenderDebugDir() {
11958
11962
  const producerRoot = process.env.PRODUCER_RENDERS_DIR ? resolve7(process.env.PRODUCER_RENDERS_DIR, "..") : resolve7(dirname10(fileURLToPath4(import.meta.url)), "../..");
11959
- return join26(producerRoot, ".debug");
11963
+ return join25(producerRoot, ".debug");
11960
11964
  }
11961
11965
  async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
11962
11966
  validateHlsRenderConfig(job.config);
11963
11967
  const debugDir = resolveRenderDebugDir();
11964
11968
  const outputDir = dirname10(outputPath);
11965
11969
  if (!existsSync16(outputDir)) mkdirSync9(outputDir, { recursive: true });
11966
- const workDir = job.config.debug ? join26(debugDir, job.id) : createRenderWorkDir(resolveRenderWorkDirPrefix(outputPath, job.id), outputDir);
11970
+ const workDir = job.config.debug ? join25(debugDir, job.id) : createRenderWorkDir(resolveRenderWorkDirPrefix(outputPath, job.id), outputDir);
11967
11971
  const pipelineStart = Date.now();
11968
11972
  const baseLog = job.config.logger ?? defaultLogger;
11969
- const logPath = job.config.debug ? join26(workDir, "render.log") : null;
11973
+ const logPath = job.config.debug ? join25(workDir, "render.log") : null;
11970
11974
  const execution = new RenderExecutionContext({
11971
11975
  request: { renderJobId: job.id, projectDir, outputPath },
11972
11976
  logger: logPath ? createRenderFileLogger(logPath, baseLog) : baseLog,
@@ -11980,7 +11984,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
11980
11984
  log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
11981
11985
  return;
11982
11986
  }
11983
- rmSync9(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
11987
+ rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
11984
11988
  });
11985
11989
  try {
11986
11990
  await executeRenderPipeline({
@@ -12024,7 +12028,7 @@ async function executeRenderPipeline(input) {
12024
12028
  imageDecodeFailures: 0
12025
12029
  };
12026
12030
  let hdrPerf;
12027
- const perfOutputPath = join26(workDir, "perf-summary.json");
12031
+ const perfOutputPath = join25(workDir, "perf-summary.json");
12028
12032
  const cfg = { ...job.config.producerConfig ?? resolveConfig2() };
12029
12033
  const observability = new RenderObservabilityRecorder({
12030
12034
  pipelineStartMs: pipelineStart,
@@ -12123,15 +12127,15 @@ async function executeRenderPipeline(input) {
12123
12127
  requestedWorkers: job.config.workers ?? "auto"
12124
12128
  });
12125
12129
  const entryFile = job.config.entryFile || "index.html";
12126
- let htmlPath = join26(projectDir, entryFile);
12130
+ let htmlPath = join25(projectDir, entryFile);
12127
12131
  if (!existsSync16(htmlPath)) {
12128
12132
  throw new Error(`Entry file not found: ${htmlPath}`);
12129
12133
  }
12130
12134
  assertNotAborted();
12131
12135
  const rawEntry = readFileSync8(htmlPath, "utf-8");
12132
12136
  if (entryFile !== "index.html" && isTemplateEntry(rawEntry)) {
12133
- const wrapperPath = join26(workDir, "standalone-entry.html");
12134
- const projectIndexPath = join26(projectDir, "index.html");
12137
+ const wrapperPath = join25(workDir, "standalone-entry.html");
12138
+ const projectIndexPath = join25(projectDir, "index.html");
12135
12139
  if (!existsSync16(projectIndexPath)) {
12136
12140
  throw new Error(
12137
12141
  `Template entry file "${entryFile}" requires a project index.html to extract its render shell.`
@@ -12294,7 +12298,7 @@ async function executeRenderPipeline(input) {
12294
12298
  updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress, {
12295
12299
  code: "extract_video"
12296
12300
  });
12297
- const compiledDir = join26(workDir, "compiled");
12301
+ const compiledDir = join25(workDir, "compiled");
12298
12302
  const extractResult = await observeRenderStage(
12299
12303
  observability,
12300
12304
  "video_extract",
@@ -12382,7 +12386,8 @@ async function executeRenderPipeline(input) {
12382
12386
  projectDir,
12383
12387
  workDir,
12384
12388
  compiledDir,
12385
- duration: probeResult.duration,
12389
+ totalFrames: probeResult.totalFrames,
12390
+ fps: job.config.fps,
12386
12391
  ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
12387
12392
  audioGain: cfg.audioGain,
12388
12393
  audios: composition.audios,
@@ -12394,6 +12399,7 @@ async function executeRenderPipeline(input) {
12394
12399
  );
12395
12400
  const { audioOutputPath, hasAudio } = audioResult;
12396
12401
  perfStages.audioProcessMs = audioResult.audioProcessMs;
12402
+ recordLimiterAttenuation(job, audioResult.audioLoweredDb);
12397
12403
  if (audioResult.audioError) {
12398
12404
  const audioFailures = audioResult.audioFailures ?? [];
12399
12405
  const failureOwner = audioFailures.length === 0 ? void 0 : audioFailures.some((failure) => failure.owner === "system") ? "system" : "user";
@@ -12426,7 +12432,7 @@ async function executeRenderPipeline(input) {
12426
12432
  try {
12427
12433
  fileServer = await createFileServer({
12428
12434
  projectDir,
12429
- compiledDir: join26(workDir, "compiled"),
12435
+ compiledDir: join25(workDir, "compiled"),
12430
12436
  port: 0,
12431
12437
  preHeadScripts: [VIRTUAL_TIME_SHIM],
12432
12438
  fps: job.config.fps
@@ -12444,7 +12450,7 @@ async function executeRenderPipeline(input) {
12444
12450
  if (!activeFileServer) {
12445
12451
  throw new Error("File server failed to initialize before frame capture");
12446
12452
  }
12447
- const framesDir = join26(workDir, "captured-frames");
12453
+ const framesDir = join25(workDir, "captured-frames");
12448
12454
  if (!existsSync16(framesDir)) mkdirSync9(framesDir, { recursive: true });
12449
12455
  updateJobStatus(job, "rendering", "Checking browser GPU", 25, onProgress, {
12450
12456
  code: "start_browsers"
@@ -12894,7 +12900,7 @@ async function executeRenderPipeline(input) {
12894
12900
  hls: ".mp4"
12895
12901
  };
12896
12902
  const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
12897
- const videoOnlyPath = join26(workDir, `video-only${videoExt}`);
12903
+ const videoOnlyPath = join25(workDir, `video-only${videoExt}`);
12898
12904
  const usePageSideCompositingForTransitions = (cfg.enablePageSideCompositing || isGif) && compiled.hasShaderTransitions && !hasHdrContent && outputSupportsPageSideShaderCompositing(outputFormat);
12899
12905
  if (usePageSideCompositingForTransitions) {
12900
12906
  activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
@@ -13137,9 +13143,10 @@ async function executeRenderPipeline(input) {
13137
13143
  outputWidth: captureCompositionWidth ?? width,
13138
13144
  outputHeight: captureCompositionHeight ?? height,
13139
13145
  motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "",
13146
+ pageMotionBlur: job.config.pageMotionBlur,
13140
13147
  captureQuality: captureOptions.quality
13141
13148
  });
13142
- const segmentDir = segmentDirFor(join26(projectDir, "renders"), segmentPlanHash);
13149
+ const segmentDir = segmentDirFor(join25(projectDir, "renders"), segmentPlanHash);
13143
13150
  let completedSegments = /* @__PURE__ */ new Set();
13144
13151
  if (job.config.resumeSegments === true) {
13145
13152
  const existing = readSegmentManifest(segmentDir);
@@ -13154,10 +13161,10 @@ async function executeRenderPipeline(input) {
13154
13161
  });
13155
13162
  }
13156
13163
  if (completedSegments.size === 0) {
13157
- rmSync9(segmentDir, { recursive: true, force: true });
13164
+ rmSync10(segmentDir, { recursive: true, force: true });
13158
13165
  }
13159
13166
  } else {
13160
- rmSync9(segmentDir, { recursive: true, force: true });
13167
+ rmSync10(segmentDir, { recursive: true, force: true });
13161
13168
  }
13162
13169
  const segmentManifest = readSegmentManifest(segmentDir) ?? {
13163
13170
  version: 1,
@@ -13238,7 +13245,7 @@ async function executeRenderPipeline(input) {
13238
13245
  );
13239
13246
  updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
13240
13247
  if (job.config.keepSegments !== true) {
13241
- rmSync9(segmentDir, { recursive: true, force: true });
13248
+ rmSync10(segmentDir, { recursive: true, force: true });
13242
13249
  }
13243
13250
  } else {
13244
13251
  capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
@@ -13507,7 +13514,7 @@ async function executeRenderPipeline(input) {
13507
13514
  "capture_disk",
13508
13515
  "drawElement capture failed; retrying with a fresh screenshot session"
13509
13516
  );
13510
- rmSync9(framesDir, { recursive: true, force: true });
13517
+ rmSync10(framesDir, { recursive: true, force: true });
13511
13518
  mkdirSync9(framesDir, { recursive: true });
13512
13519
  resetCaptureAttemptProgress(job);
13513
13520
  dedupPerfs.length = 0;
@@ -13694,7 +13701,7 @@ async function executeRenderPipeline(input) {
13694
13701
  }
13695
13702
  if (job.config.debug) {
13696
13703
  if (!isPngSequence && !isHls && existsSync16(stagedOutputPath)) {
13697
- const debugOutput = join26(workDir, `output${videoExt}`);
13704
+ const debugOutput = join25(workDir, `output${videoExt}`);
13698
13705
  copyFileSync4(stagedOutputPath, debugOutput);
13699
13706
  }
13700
13707
  }
@@ -13824,10 +13831,10 @@ import {
13824
13831
  linkSync,
13825
13832
  mkdirSync as mkdirSync13,
13826
13833
  renameSync as renameSync6,
13827
- rmSync as rmSync11,
13834
+ rmSync as rmSync12,
13828
13835
  writeFileSync as writeFileSync8
13829
13836
  } from "node:fs";
13830
- import { extname as extname6, isAbsolute as isAbsolute3, join as join34, relative as relative5, sep as sep2 } from "node:path";
13837
+ import { extname as extname6, isAbsolute as isAbsolute3, join as join33, relative as relative5, sep as sep2 } from "node:path";
13831
13838
  import {
13832
13839
  fpsToNumber as fpsToNumber10,
13833
13840
  HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
@@ -13839,7 +13846,7 @@ import {
13839
13846
  } from "@hyperframes/engine";
13840
13847
 
13841
13848
  // src/services/distributed/planFrameCost.ts
13842
- import { join as join29 } from "node:path";
13849
+ import { join as join28 } from "node:path";
13843
13850
  import {
13844
13851
  closeCaptureSession as closeCaptureSession9,
13845
13852
  createCaptureSession as createCaptureSession9,
@@ -13849,7 +13856,7 @@ import {
13849
13856
 
13850
13857
  // src/services/distributed/chunkFrames.ts
13851
13858
  import { existsSync as existsSync18, readdirSync as readdirSync6 } from "node:fs";
13852
- import { extname as extname4, join as join28 } from "node:path";
13859
+ import { extname as extname4, join as join27 } from "node:path";
13853
13860
  import {
13854
13861
  compositionRequiresWebGpu as compositionRequiresWebGpu3,
13855
13862
  createFrameLookupTable as createFrameLookupTable2,
@@ -13859,7 +13866,7 @@ import {
13859
13866
 
13860
13867
  // src/services/distributed/shared.ts
13861
13868
  import { execFile as execFileCallback } from "node:child_process";
13862
- import { dirname as dirname11, join as join27 } from "node:path";
13869
+ import { dirname as dirname11, join as join26 } from "node:path";
13863
13870
  import { existsSync as existsSync17, readFileSync as readFileSync9 } from "node:fs";
13864
13871
  import { fileURLToPath as fileURLToPath5 } from "node:url";
13865
13872
  import { promisify as promisify2 } from "node:util";
@@ -13875,7 +13882,7 @@ function isPlanAudioArtifactPath(path) {
13875
13882
  }
13876
13883
  function resolvePlanAudioPath(planDir) {
13877
13884
  for (const name of [PLAN_AUDIO_RELATIVE_PATH, PLAN_AUDIO_LEGACY_RELATIVE_PATH]) {
13878
- const candidate = join27(planDir, name);
13885
+ const candidate = join26(planDir, name);
13879
13886
  if (existsSync17(candidate)) return candidate;
13880
13887
  }
13881
13888
  return null;
@@ -14131,7 +14138,7 @@ function readProducerVersion() {
14131
14138
  const startDir = dirname11(fileURLToPath5(import.meta.url));
14132
14139
  let current = startDir;
14133
14140
  for (let i = 0; i < 10; i++) {
14134
- const candidate = join27(current, "package.json");
14141
+ const candidate = join26(current, "package.json");
14135
14142
  if (existsSync17(candidate)) {
14136
14143
  try {
14137
14144
  const pkg = JSON.parse(readFileSync9(candidate, "utf-8"));
@@ -14184,7 +14191,7 @@ function frameNumberFromFileName(name) {
14184
14191
  function rebuildExtractedFramesFromPlanDir(planDir, videos, indexMode = "dense-v1") {
14185
14192
  const result = [];
14186
14193
  for (const v of videos) {
14187
- const outputDir = join28(planDir, "video-frames", v.videoId);
14194
+ const outputDir = join27(planDir, "video-frames", v.videoId);
14188
14195
  if (!existsSync18(outputDir)) {
14189
14196
  throw new Error(
14190
14197
  `[renderChunk] planDir missing extracted video frames for ${JSON.stringify(v.videoId)}: ${outputDir} not present. plan() should have written frames here; the planDir is malformed.`
@@ -14203,7 +14210,7 @@ function rebuildExtractedFramesFromPlanDir(planDir, videos, indexMode = "dense-v
14203
14210
  if (!frameName) continue;
14204
14211
  const frameNumber = indexMode === "sparse-v2" ? frameNumberFromFileName(frameName) : null;
14205
14212
  const frameIndex = frameNumber === null ? i : frameNumber - 1;
14206
- framePaths.set(frameIndex, join28(outputDir, frameName));
14213
+ framePaths.set(frameIndex, join27(outputDir, frameName));
14207
14214
  }
14208
14215
  result.push({
14209
14216
  videoId: v.videoId,
@@ -14245,7 +14252,7 @@ function buildChunkCaptureOptions(input) {
14245
14252
  skipReadinessVideoIds: input.skipReadinessVideoIds,
14246
14253
  videoMetadataHints: input.videoMetadataHints,
14247
14254
  ...videoCaptureBeyondViewport !== void 0 ? { captureBeyondViewport: videoCaptureBeyondViewport } : {},
14248
- pageMotionBlur: "distributed-render",
14255
+ pageMotionBlur: encoder.pageMotionBlur ?? "distributed-render",
14249
14256
  // A fixed warmup count keeps `beginFrameTimeTicks` host-independent.
14250
14257
  lockWarmupTicks: true,
14251
14258
  requiresWebGpu: compositionRequiresWebGpu3(input.html)
@@ -14293,14 +14300,14 @@ async function extractDeferredVideoFramesForChunk(input) {
14293
14300
  const shown = shownDeferredFrameIndexes(input.planVideos, input.frames, input.fps);
14294
14301
  return Promise.all(
14295
14302
  deferred.map(async (video) => {
14296
- const outputDir = join28(input.outputRoot, video.videoId);
14303
+ const outputDir = join27(input.outputRoot, video.videoId);
14297
14304
  const indexes = [...shown.get(video.videoId) ?? []].sort((a, b) => a - b);
14298
14305
  if (indexes.length === 0) {
14299
14306
  return { ...video, outputDir, framePaths: /* @__PURE__ */ new Map(), ownedByLookup: false };
14300
14307
  }
14301
14308
  const range = video.deferredRange;
14302
14309
  const remoteUrl = remoteSources[range.sourcePath];
14303
- const sourcePath = remoteUrl ?? join28(input.planDir, ...range.sourcePath.split("/"));
14310
+ const sourcePath = remoteUrl ?? join27(input.planDir, ...range.sourcePath.split("/"));
14304
14311
  if (remoteUrl === void 0 && !existsSync18(sourcePath)) {
14305
14312
  throw new RenderChunkValidationError(
14306
14313
  MISSING_PLAN_ARTIFACT,
@@ -14349,12 +14356,12 @@ async function measurePlanSecondsPerFrame(input) {
14349
14356
  planVideos,
14350
14357
  frames: sampled,
14351
14358
  fps: fpsRatio,
14352
- outputRoot: join29(workDir, "frame-cost-video-frames"),
14359
+ outputRoot: join28(workDir, "frame-cost-video-frames"),
14353
14360
  cfg: input.cfg
14354
14361
  })
14355
14362
  ];
14356
14363
  const lookup = extracted.length > 0 ? createFrameLookupTable3(planVideos.videos, extracted, resolveRenderFpsConfig(fpsRatio).value) : null;
14357
- const compiledDir = join29(planDir, "compiled");
14364
+ const compiledDir = join28(planDir, "compiled");
14358
14365
  const fileServer = await createFileServer({
14359
14366
  projectDir: compiledDir,
14360
14367
  compiledDir,
@@ -14366,7 +14373,7 @@ async function measurePlanSecondsPerFrame(input) {
14366
14373
  try {
14367
14374
  session = await createCaptureSession9(
14368
14375
  fileServer.url,
14369
- join29(workDir, "frame-cost"),
14376
+ join28(workDir, "frame-cost"),
14370
14377
  buildChunkCaptureOptions({
14371
14378
  dimensions: { ...input, fpsNum: fps, fpsDen: 1 },
14372
14379
  encoder: input,
@@ -14388,7 +14395,7 @@ async function measurePlanSecondsPerFrame(input) {
14388
14395
 
14389
14396
  // src/services/render/stages/freezePlan.ts
14390
14397
  import { existsSync as existsSync19, mkdirSync as mkdirSync10, readFileSync as readFileSync10, readdirSync as readdirSync7, writeFileSync as writeFileSync6 } from "node:fs";
14391
- import { join as join30, relative as relative3, resolve as resolve8 } from "node:path";
14398
+ import { join as join29, relative as relative3, resolve as resolve8 } from "node:path";
14392
14399
 
14393
14400
  // src/services/distributed/planProtocol.ts
14394
14401
  var PLAN_SCHEMA_VERSION = 1;
@@ -14606,7 +14613,7 @@ function listPlanFiles(planDir) {
14606
14613
  function walk(dir) {
14607
14614
  const entries = readdirSync7(dir, { withFileTypes: true });
14608
14615
  for (const entry of entries) {
14609
- const full = join30(dir, entry.name);
14616
+ const full = join29(dir, entry.name);
14610
14617
  if (entry.isDirectory()) {
14611
14618
  walk(full);
14612
14619
  } else if (entry.isFile()) {
@@ -14647,15 +14654,15 @@ var PLAN_REMOTE_FILES_RELATIVE_PATH = "meta/remote-files.json";
14647
14654
  function writePlanRemoteFiles(planDir, files) {
14648
14655
  if (files.length === 0) return;
14649
14656
  const sorted = [...files].sort((a, b) => a.path < b.path ? -1 : 1);
14650
- writeFileSync6(join30(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH), JSON.stringify(sorted), "utf-8");
14657
+ writeFileSync6(join29(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH), JSON.stringify(sorted), "utf-8");
14651
14658
  }
14652
14659
  function readPlanRemoteFiles(planDir) {
14653
- const path = join30(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH);
14660
+ const path = join29(planDir, PLAN_REMOTE_FILES_RELATIVE_PATH);
14654
14661
  return existsSync19(path) ? JSON.parse(readFileSync10(path, "utf-8")) : [];
14655
14662
  }
14656
14663
  function recomputePlanHashFromPlanDir(planDir, digests) {
14657
- const planJsonPath = join30(planDir, "plan.json");
14658
- const encoderJsonPath = join30(planDir, "meta", "encoder.json");
14664
+ const planJsonPath = join29(planDir, "plan.json");
14665
+ const encoderJsonPath = join29(planDir, "meta", "encoder.json");
14659
14666
  if (!existsSync19(planJsonPath)) {
14660
14667
  throw new Error(`[freezePlan] plan.json missing: ${planJsonPath}`);
14661
14668
  }
@@ -14692,18 +14699,18 @@ async function freezePlan(input) {
14692
14699
  if (!existsSync19(planDir)) {
14693
14700
  throw new Error(`[freezePlan] planDir does not exist: ${planDir}`);
14694
14701
  }
14695
- const metaDir = join30(planDir, "meta");
14702
+ const metaDir = join29(planDir, "meta");
14696
14703
  if (!existsSync19(metaDir)) mkdirSync10(metaDir, { recursive: true });
14697
14704
  writeFileSync6(
14698
- join30(metaDir, "composition.json"),
14705
+ join29(metaDir, "composition.json"),
14699
14706
  `${JSON.stringify(composition, null, 2)}
14700
14707
  `,
14701
14708
  "utf-8"
14702
14709
  );
14703
14710
  const encoderForCanonical = stripUndefined(encoder);
14704
14711
  const encoderConfigCanonicalJson = canonicalJsonStringify(encoderForCanonical);
14705
- writeFileSync6(join30(metaDir, "encoder.json"), encoderConfigCanonicalJson, "utf-8");
14706
- writeFileSync6(join30(metaDir, "chunks.json"), `${JSON.stringify(chunks, null, 2)}
14712
+ writeFileSync6(join29(metaDir, "encoder.json"), encoderConfigCanonicalJson, "utf-8");
14713
+ writeFileSync6(join29(metaDir, "chunks.json"), `${JSON.stringify(chunks, null, 2)}
14707
14714
  `, "utf-8");
14708
14715
  const { compositionHtml, assets } = collectPlanAssetShas(planDir, digests);
14709
14716
  const planHash = computePlanHash({
@@ -14727,7 +14734,7 @@ async function freezePlan(input) {
14727
14734
  duration: durationSeconds,
14728
14735
  hasAudio
14729
14736
  };
14730
- const planJsonPath = join30(planDir, "plan.json");
14737
+ const planJsonPath = join29(planDir, "plan.json");
14731
14738
  writeFileSync6(planJsonPath, `${JSON.stringify(planJson, null, 2)}
14732
14739
  `, "utf-8");
14733
14740
  return { planJsonPath, planHash };
@@ -14771,7 +14778,7 @@ function applyRuntimeEnvSnapshot(snapshot, env = process.env) {
14771
14778
  // src/services/distributed/chunkAudio.ts
14772
14779
  import { closeSync as closeSync6, existsSync as existsSync20, ftruncateSync, mkdirSync as mkdirSync12, openSync as openSync5, readFileSync as readFileSync11 } from "node:fs";
14773
14780
  import { writeFileSync as writeFileSync7, writeSync } from "node:fs";
14774
- import { join as join32 } from "node:path";
14781
+ import { join as join31 } from "node:path";
14775
14782
  import { appendRenderProvenanceArgs, describeFfmpegFailure as describeFfmpegFailure3, runFfmpeg as runFfmpeg6 } from "@hyperframes/engine";
14776
14783
 
14777
14784
  // src/services/distributed/mp4Stitch.ts
@@ -14781,10 +14788,10 @@ import {
14781
14788
  mkdirSync as mkdirSync11,
14782
14789
  mkdtempSync as mkdtempSync4,
14783
14790
  renameSync as renameSync5,
14784
- rmSync as rmSync10
14791
+ rmSync as rmSync11
14785
14792
  } from "node:fs";
14786
14793
  import { open } from "node:fs/promises";
14787
- import { dirname as dirname12, join as join31 } from "node:path";
14794
+ import { dirname as dirname12, join as join30 } from "node:path";
14788
14795
  import { finished, pipeline } from "node:stream/promises";
14789
14796
  function boxAt(src, off, at, limit) {
14790
14797
  let size = src.readUInt32BE(off);
@@ -15150,16 +15157,16 @@ async function writeStitchedMp4(chunkPaths, outputPath, audioPath) {
15150
15157
  const header = audioPath ? buildStitchedHeader(layouts.slice(1), layouts[0]) : buildStitchedHeader(layouts);
15151
15158
  mkdirSync11(dirname12(outputPath), { recursive: true });
15152
15159
  const workDir = mkdtempSync4(`${outputPath}.stitch-`);
15153
- const out = createWriteStream(join31(workDir, "out.mp4"));
15160
+ const out = createWriteStream(join30(workDir, "out.mp4"));
15154
15161
  try {
15155
15162
  out.write(header);
15156
15163
  for (const [i, path] of sourcePaths.entries()) await copyPayload(path, layouts[i], i, out);
15157
15164
  out.end();
15158
15165
  await finished(out);
15159
- renameSync5(join31(workDir, "out.mp4"), outputPath);
15166
+ renameSync5(join30(workDir, "out.mp4"), outputPath);
15160
15167
  } finally {
15161
15168
  out.destroy();
15162
- rmSync10(workDir, { recursive: true, force: true });
15169
+ rmSync11(workDir, { recursive: true, force: true });
15163
15170
  }
15164
15171
  }
15165
15172
 
@@ -15250,7 +15257,7 @@ async function writeChunkAudioSlices(input) {
15250
15257
  });
15251
15258
  const bytes = readFileSync11(input.audioPath);
15252
15259
  const layout = { sizeBytes: bytes.length, payloadStart, payloadEnd, partStarts: {} };
15253
- mkdirSync12(join32(input.planDir, SLICE_DIR), { recursive: true });
15260
+ mkdirSync12(join31(input.planDir, SLICE_DIR), { recursive: true });
15254
15261
  input.chunks.forEach((_, index) => {
15255
15262
  const cut = audioCutForChunk(grid, input.chunks, index, input.fps);
15256
15263
  if (!cut) return;
@@ -15258,17 +15265,17 @@ async function writeChunkAudioSlices(input) {
15258
15265
  const start = offsets[cut.firstPacket];
15259
15266
  layout.partStarts[index] = start;
15260
15267
  const end = offsets[last] + track.sizes[last];
15261
- writeFileSync7(join32(input.planDir, chunkAudioSlicePath(index)), bytes.subarray(start, end));
15268
+ writeFileSync7(join31(input.planDir, chunkAudioSlicePath(index)), bytes.subarray(start, end));
15262
15269
  });
15263
15270
  const frame = [bytes.subarray(0, payloadStart), bytes.subarray(payloadEnd)];
15264
- writeFileSync7(join32(input.planDir, SLICE_FRAME), Buffer.concat(frame));
15265
- writeFileSync7(join32(input.planDir, SLICE_LAYOUT), JSON.stringify(layout));
15271
+ writeFileSync7(join31(input.planDir, SLICE_FRAME), Buffer.concat(frame));
15272
+ writeFileSync7(join31(input.planDir, SLICE_LAYOUT), JSON.stringify(layout));
15266
15273
  }
15267
15274
  function chunkAudioSource(planDir, chunkIndex, scratchPath) {
15268
- const layoutPath = join32(planDir, SLICE_LAYOUT);
15275
+ const layoutPath = join31(planDir, SLICE_LAYOUT);
15269
15276
  if (!existsSync20(layoutPath)) return resolvePlanAudioPath(planDir);
15270
15277
  const layout = JSON.parse(readFileSync11(layoutPath, "utf-8"));
15271
- const frame = readFileSync11(join32(planDir, SLICE_FRAME));
15278
+ const frame = readFileSync11(join31(planDir, SLICE_FRAME));
15272
15279
  const fd = openSync5(scratchPath, "w");
15273
15280
  try {
15274
15281
  writeSync(fd, frame, 0, layout.payloadStart, 0);
@@ -15281,7 +15288,7 @@ function chunkAudioSource(planDir, chunkIndex, scratchPath) {
15281
15288
  );
15282
15289
  const start = layout.partStarts[chunkIndex];
15283
15290
  if (start !== void 0) {
15284
- const part = readFileSync11(join32(planDir, chunkAudioSlicePath(chunkIndex)));
15291
+ const part = readFileSync11(join31(planDir, chunkAudioSlicePath(chunkIndex)));
15285
15292
  writeSync(fd, part, 0, part.length, start);
15286
15293
  }
15287
15294
  ftruncateSync(fd, layout.sizeBytes);
@@ -15337,7 +15344,7 @@ async function muxChunkWithAudio(input) {
15337
15344
  // src/services/distributed/planSize.ts
15338
15345
  import { createHash as createHash7 } from "node:crypto";
15339
15346
  import { lstatSync as lstatSync2, readdirSync as readdirSync8 } from "node:fs";
15340
- import { extname as extname5, join as join33, relative as relative4 } from "node:path";
15347
+ import { extname as extname5, join as join32, relative as relative4 } from "node:path";
15341
15348
  var MAX_TOP_COMPONENTS = 10;
15342
15349
  var SOURCE_MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
15343
15350
  ".aac",
@@ -15449,7 +15456,7 @@ function walkRegularFiles(dir, visit) {
15449
15456
  return;
15450
15457
  }
15451
15458
  for (const entry of entries) {
15452
- const full = join33(dir, entry.name);
15459
+ const full = join32(dir, entry.name);
15453
15460
  if (entry.isDirectory()) {
15454
15461
  walkRegularFiles(full, visit);
15455
15462
  } else if (entry.isFile()) {
@@ -15524,7 +15531,6 @@ function chunkSizingUsesFrameCost(config) {
15524
15531
  }
15525
15532
  var CHUNK_EXCEEDS_WORKER_TIMEOUT = "CHUNK_EXCEEDS_WORKER_TIMEOUT";
15526
15533
  var ChunkExceedsWorkerTimeoutError = class extends Error {
15527
- // fallow-ignore-next-line unused-class-member
15528
15534
  code = CHUNK_EXCEEDS_WORKER_TIMEOUT;
15529
15535
  constructor(predictedChunkSeconds, workerTimeoutSeconds) {
15530
15536
  super(
@@ -15587,7 +15593,6 @@ var PlanTooLargeError = class extends Error {
15587
15593
  };
15588
15594
  var FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED";
15589
15595
  var FormatNotSupportedInDistributedError = class extends Error {
15590
- // fallow-ignore-next-line unused-class-member
15591
15596
  code = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
15592
15597
  format;
15593
15598
  reason;
@@ -15659,13 +15664,13 @@ function assertPositiveInteger2(name, value) {
15659
15664
  }
15660
15665
  var FREEZE_OWNED_PLAN_FILES = [
15661
15666
  "plan.json",
15662
- join34("meta", "composition.json"),
15663
- join34("meta", "encoder.json"),
15664
- join34("meta", "chunks.json")
15667
+ join33("meta", "composition.json"),
15668
+ join33("meta", "encoder.json"),
15669
+ join33("meta", "chunks.json")
15665
15670
  ];
15666
15671
  function removeFreezeOwnedPlanFiles(planDir) {
15667
15672
  for (const relativePath of FREEZE_OWNED_PLAN_FILES) {
15668
- rmSync11(join34(planDir, relativePath), { force: true });
15673
+ rmSync12(join33(planDir, relativePath), { force: true });
15669
15674
  }
15670
15675
  }
15671
15676
  function buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize) {
@@ -15724,6 +15729,7 @@ function buildLockedRenderConfig(input) {
15724
15729
  chunkCount: input.chunkCount,
15725
15730
  runtimeEnv: input.runtimeEnv,
15726
15731
  variables: config.variables,
15732
+ pageMotionBlur: config.pageMotionBlur,
15727
15733
  ...input.chunksCarryAudio ? { chunksCarryAudio: true } : {},
15728
15734
  ...input.separateAudio ? { separateAudio: true } : {}
15729
15735
  };
@@ -15797,19 +15803,19 @@ function createVideoSourceShipper(input) {
15797
15803
  const known = shipped.get(srcPath);
15798
15804
  if (known) return known;
15799
15805
  const stagedRel = pathInside(input.workVideoFramesDir, srcPath);
15800
- const current = stagedRel === null ? srcPath : join34(input.planDir, "video-frames", stagedRel);
15806
+ const current = stagedRel === null ? srcPath : join33(input.planDir, "video-frames", stagedRel);
15801
15807
  let result = null;
15802
15808
  for (const root of stagedRel === null ? [input.workCompiledDir, input.projectDir] : []) {
15803
15809
  const rel = pathInside(root, srcPath);
15804
- if (rel !== null && (existsSync21(join34(input.planDir, "compiled", rel)) || input.isRemote(srcPath))) {
15810
+ if (rel !== null && (existsSync21(join33(input.planDir, "compiled", rel)) || input.isRemote(srcPath))) {
15805
15811
  result = ["compiled", ...rel.split(sep2)].join("/");
15806
15812
  break;
15807
15813
  }
15808
15814
  }
15809
15815
  if (result === null) {
15810
15816
  result = `video-sources/${shipped.size}${extname6(current)}`;
15811
- const destination = join34(input.planDir, ...result.split("/"));
15812
- mkdirSync13(join34(input.planDir, "video-sources"), { recursive: true });
15817
+ const destination = join33(input.planDir, ...result.split("/"));
15818
+ mkdirSync13(join33(input.planDir, "video-sources"), { recursive: true });
15813
15819
  try {
15814
15820
  linkSync(current, destination);
15815
15821
  } catch {
@@ -15821,7 +15827,7 @@ function createVideoSourceShipper(input) {
15821
15827
  };
15822
15828
  }
15823
15829
  function plansSeparateAudio(config, options) {
15824
- return options.deferAudio === true && config.format === "mp4" && config.cfr !== true;
15830
+ return options.deferAudio === true && config.format !== "png-sequence" && config.cfr !== true;
15825
15831
  }
15826
15832
  async function compilePlanComposition(projectDir, config, planDir, options) {
15827
15833
  rejectUnsupportedDistributedFormat(config);
@@ -15858,14 +15864,14 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
15858
15864
  variables: config.variables
15859
15865
  });
15860
15866
  const entryFile = config.entryFile ?? "index.html";
15861
- const htmlPath = join34(projectDir, entryFile);
15867
+ const htmlPath = join33(projectDir, entryFile);
15862
15868
  if (!existsSync21(htmlPath)) {
15863
15869
  throw new Error(`[plan] entry file not found: ${htmlPath}`);
15864
15870
  }
15865
- const workDir = join34(planDir, ".plan-work");
15866
- rmSync11(workDir, { recursive: true, force: true });
15871
+ const workDir = join33(planDir, ".plan-work");
15872
+ rmSync12(workDir, { recursive: true, force: true });
15867
15873
  mkdirSync13(workDir, { recursive: true });
15868
- const compiledDir = join34(workDir, "compiled");
15874
+ const compiledDir = join33(workDir, "compiled");
15869
15875
  mkdirSync13(compiledDir, { recursive: true });
15870
15876
  let stageStarted = Date.now();
15871
15877
  cpSync2(projectDir, compiledDir, {
@@ -15879,10 +15885,10 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
15879
15885
  }
15880
15886
  });
15881
15887
  logPlanStage(log, "copy", stageStarted);
15882
- const finalCompiledDir = join34(planDir, "compiled");
15888
+ const finalCompiledDir = join33(planDir, "compiled");
15883
15889
  const needsAlpha = outputNeedsAlpha(config.format);
15884
15890
  const remoteFiles = Object.entries(options.remoteFiles ?? {});
15885
- const remoteLocalPath = (rel) => join34(projectDir, ...rel.split("/"));
15891
+ const remoteLocalPath = (rel) => join33(projectDir, ...rel.split("/"));
15886
15892
  const urlByAbsentLocalPath = new Map(
15887
15893
  remoteFiles.map(([rel, file]) => [remoteLocalPath(rel), file.url])
15888
15894
  );
@@ -15981,22 +15987,21 @@ async function compilePlanComposition(projectDir, config, planDir, options) {
15981
15987
  forceScreenshot,
15982
15988
  width,
15983
15989
  height,
15984
- probeResult,
15985
15990
  totalFrames
15986
15991
  };
15987
15992
  }
15988
- function mixPlanAudio(c, compiledDir, seconds, pcmPath) {
15993
+ function mixPlanAudio(c, compiledDir) {
15989
15994
  return runAudioStage({
15990
15995
  projectDir: c.projectDir,
15991
15996
  workDir: c.workDir,
15992
15997
  compiledDir,
15993
15998
  urlByAbsentLocalPath: c.urlByAbsentLocalPath,
15994
- duration: seconds,
15999
+ totalFrames: c.totalFrames,
16000
+ fps: c.job.config.fps,
15995
16001
  ffmpegProcessTimeout: c.cfg.ffmpegProcessTimeout,
15996
16002
  audioGain: c.cfg.audioGain,
15997
16003
  audios: c.composition.audios,
15998
16004
  masterFxChain: c.composition.masterFxChain,
15999
- ...pcmPath ? { keepEncoderInputAt: pcmPath } : {},
16000
16005
  abortSignal: c.abortSignal,
16001
16006
  assertNotAborted: c.assertNotAborted
16002
16007
  });
@@ -16020,16 +16025,6 @@ async function extractPlanVideos(c, deferRangeExtraction) {
16020
16025
  }
16021
16026
  return extractResult;
16022
16027
  }
16023
- async function holdPlanAudioPeak(c, audioPath, pcmPath, seconds) {
16024
- const peak = await constrainAacTruePeak({
16025
- audioPath,
16026
- ...existsSync21(pcmPath) ? { sourcePath: pcmPath } : {},
16027
- targetDurationSeconds: seconds,
16028
- signal: c.abortSignal,
16029
- timeoutMs: c.cfg.ffmpegProcessTimeout
16030
- }).finally(() => rmSync11(pcmPath, { force: true }));
16031
- if (!peak.success) throw encoderFailureError("[plan] audio true-peak check failed", peak);
16032
- }
16033
16028
  async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, options = {}) {
16034
16029
  const planDir = executionPlanDir;
16035
16030
  const c = await compilePlanComposition(projectDir, config, planDir, options);
@@ -16051,7 +16046,6 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
16051
16046
  forceScreenshot,
16052
16047
  width,
16053
16048
  height,
16054
- probeResult,
16055
16049
  totalFrames
16056
16050
  } = c;
16057
16051
  const megapixels = compileResult.outputWidth * compileResult.outputHeight / 1e6;
@@ -16067,17 +16061,17 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
16067
16061
  let stageStarted = Date.now();
16068
16062
  const extractResult = await extractPlanVideos(c, options.deferVideoExtraction);
16069
16063
  logPlanStage(log, "extract", stageStarted);
16070
- const stagedVideoFrames = join34(compiledDir, "__hyperframes_video_frames");
16071
- const videoFramesDst = join34(planDir, "video-frames");
16072
- if (existsSync21(videoFramesDst)) rmSync11(videoFramesDst, { recursive: true, force: true });
16073
- const videoSourcesDir = join34(planDir, "video-sources");
16074
- rmSync11(videoSourcesDir, { recursive: true, force: true });
16064
+ const stagedVideoFrames = join33(compiledDir, "__hyperframes_video_frames");
16065
+ const videoFramesDst = join33(planDir, "video-frames");
16066
+ if (existsSync21(videoFramesDst)) rmSync12(videoFramesDst, { recursive: true, force: true });
16067
+ const videoSourcesDir = join33(planDir, "video-sources");
16068
+ rmSync12(videoSourcesDir, { recursive: true, force: true });
16075
16069
  if (existsSync21(stagedVideoFrames)) {
16076
16070
  renameSync6(stagedVideoFrames, videoFramesDst);
16077
16071
  } else {
16078
16072
  mkdirSync13(videoFramesDst, { recursive: true });
16079
16073
  }
16080
- if (existsSync21(finalCompiledDir)) rmSync11(finalCompiledDir, { recursive: true, force: true });
16074
+ if (existsSync21(finalCompiledDir)) rmSync12(finalCompiledDir, { recursive: true, force: true });
16081
16075
  renameSync6(compiledDir, finalCompiledDir);
16082
16076
  const shipVideoSource = createVideoSourceShipper({
16083
16077
  planDir,
@@ -16107,7 +16101,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
16107
16101
  };
16108
16102
  })
16109
16103
  });
16110
- mkdirSync13(join34(planDir, "meta"), { recursive: true });
16104
+ mkdirSync13(join33(planDir, "meta"), { recursive: true });
16111
16105
  writePlanRemoteFiles(
16112
16106
  planDir,
16113
16107
  remoteFiles.map(([rel, { sha256, sizeBytes }]) => ({
@@ -16117,23 +16111,16 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
16117
16111
  }))
16118
16112
  );
16119
16113
  writeFileSync8(
16120
- join34(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
16114
+ join33(planDir, PLAN_VIDEOS_META_RELATIVE_PATH),
16121
16115
  JSON.stringify(planVideosJson, null, 2),
16122
16116
  "utf-8"
16123
16117
  );
16124
16118
  const digests = options.digests ?? /* @__PURE__ */ new Map();
16125
16119
  const separateAudio = plansSeparateAudio(config, options);
16126
16120
  const chunksCarryAudio = !separateAudio && carriesChunkAudio(config, options);
16127
- const audioSeconds = chunksCarryAudio ? totalFrames / config.fps : probeResult.duration;
16128
- const planMixPcmPath = join34(workDir, "plan-mix.wav");
16129
16121
  stageStarted = Date.now();
16130
16122
  const [audioOutcome, overlapOutcome, frameCostOutcome] = await Promise.allSettled([
16131
- separateAudio ? null : mixPlanAudio(
16132
- c,
16133
- finalCompiledDir,
16134
- audioSeconds,
16135
- chunksCarryAudio ? planMixPcmPath : void 0
16136
- ).finally(() => logPlanStage(log, "audio", stageStarted)),
16123
+ separateAudio ? null : mixPlanAudio(c, finalCompiledDir).finally(() => logPlanStage(log, "audio", stageStarted)),
16137
16124
  (async () => {
16138
16125
  for (const dir of [finalCompiledDir, videoFramesDst, videoSourcesDir]) {
16139
16126
  await prehashPlanFiles(dir, digests);
@@ -16186,14 +16173,11 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
16186
16173
  if (audioResult?.audioError) {
16187
16174
  applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
16188
16175
  }
16189
- const planAudioPath = join34(planDir, PLAN_AUDIO_RELATIVE_PATH);
16176
+ const planAudioPath = join33(planDir, PLAN_AUDIO_RELATIVE_PATH);
16190
16177
  if (audioResult?.hasAudio && existsSync21(audioResult.audioOutputPath)) {
16191
16178
  renameSync6(audioResult.audioOutputPath, planAudioPath);
16192
16179
  }
16193
16180
  if (chunksCarryAudio && existsSync21(planAudioPath)) {
16194
- stageStarted = Date.now();
16195
- await holdPlanAudioPeak(c, planAudioPath, planMixPcmPath, audioSeconds);
16196
- logPlanStage(log, "audio-peak", stageStarted);
16197
16181
  if (options.sliceChunkAudio) {
16198
16182
  stageStarted = Date.now();
16199
16183
  await writeChunkAudioSlices({
@@ -16238,7 +16222,7 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
16238
16222
  format: config.format
16239
16223
  };
16240
16224
  try {
16241
- rmSync11(workDir, { recursive: true, force: true });
16225
+ rmSync12(workDir, { recursive: true, force: true });
16242
16226
  } catch (err) {
16243
16227
  log.warn("[plan] failed to remove temp work dir", {
16244
16228
  workDir,
@@ -16343,6 +16327,9 @@ function validateDistributedRenderConfig(config) {
16343
16327
  }
16344
16328
  validateIntDimension("config.width", config.width);
16345
16329
  validateIntDimension("config.height", config.height);
16330
+ if (config.pageMotionBlur !== void 0 && config.pageMotionBlur !== "off") {
16331
+ throw new InvalidConfigError("config.pageMotionBlur", 'must be "off" when provided');
16332
+ }
16346
16333
  if (!ALLOWED_FORMATS.includes(config.format)) {
16347
16334
  throw new InvalidConfigError(
16348
16335
  "config.format",
@@ -16716,6 +16703,7 @@ function distributedConfigFromRequest(request, runtime = {}) {
16716
16703
  codec: distributed.codec,
16717
16704
  quality: options.quality,
16718
16705
  crf: options.crf,
16706
+ pageMotionBlur: options.pageMotionBlur,
16719
16707
  bitrate: options.videoBitrate,
16720
16708
  videoFrameFormat: options.videoFrameFormat,
16721
16709
  outputResolution: options.outputResolution,
@@ -16762,6 +16750,7 @@ function renderRequestFromDistributedConfig(input) {
16762
16750
  format: config.format,
16763
16751
  distributed,
16764
16752
  ...optionalProperty("crf", config.crf),
16753
+ ...optionalProperty("pageMotionBlur", config.pageMotionBlur),
16765
16754
  ...optionalProperty("videoBitrate", config.bitrate),
16766
16755
  ...optionalProperty("videoFrameFormat", config.videoFrameFormat),
16767
16756
  ...optionalProperty("outputResolution", config.outputResolution),
@@ -16808,10 +16797,10 @@ import {
16808
16797
  statSync as statSync8,
16809
16798
  mkdtempSync as mkdtempSync5,
16810
16799
  writeFileSync as writeFileSync9,
16811
- rmSync as rmSync12,
16800
+ rmSync as rmSync13,
16812
16801
  createReadStream as createReadStream5
16813
16802
  } from "node:fs";
16814
- import { resolve as resolve10, dirname as dirname14, join as join37 } from "node:path";
16803
+ import { resolve as resolve10, dirname as dirname14, join as join36 } from "node:path";
16815
16804
  import { tmpdir as tmpdir3 } from "node:os";
16816
16805
  import { parseArgs } from "node:util";
16817
16806
  import crypto from "node:crypto";
@@ -16829,7 +16818,7 @@ import {
16829
16818
  closeSync as closeSync7,
16830
16819
  constants as constants3
16831
16820
  } from "node:fs";
16832
- import { resolve as resolve9, join as join35, relative as relative6, isAbsolute as isAbsolute4, sep as sep3 } from "node:path";
16821
+ import { resolve as resolve9, join as join34, relative as relative6, isAbsolute as isAbsolute4, sep as sep3 } from "node:path";
16833
16822
  import { lintHyperframeHtml } from "@hyperframes/lint";
16834
16823
  function isStringRecord(value) {
16835
16824
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -16897,7 +16886,7 @@ function readProjectEntryFile(projectDir, preferredEntryFile) {
16897
16886
  }
16898
16887
  }
16899
16888
  return {
16900
- error: `No HTML entry file found in project directory: ${join35(absProjectDir, preferredEntryFile || "index.html")}`
16889
+ error: `No HTML entry file found in project directory: ${join34(absProjectDir, preferredEntryFile || "index.html")}`
16901
16890
  };
16902
16891
  }
16903
16892
  function prepareHyperframeLintBody(body) {
@@ -16943,7 +16932,7 @@ async function runHyperframeLint(prepared) {
16943
16932
  // src/services/healthWorker.ts
16944
16933
  import { Worker as Worker2 } from "node:worker_threads";
16945
16934
  import { fileURLToPath as fileURLToPath6 } from "node:url";
16946
- import { dirname as dirname13, join as join36 } from "node:path";
16935
+ import { dirname as dirname13, join as join35 } from "node:path";
16947
16936
  import { existsSync as existsSync23 } from "node:fs";
16948
16937
  var DEFAULT_HEALTH_PORT = 9848;
16949
16938
  async function startHealthWorker(options = {}) {
@@ -17019,7 +17008,7 @@ function defaultLogger2() {
17019
17008
  }
17020
17009
  function resolveWorkerEntry2() {
17021
17010
  const here = dirname13(fileURLToPath6(import.meta.url));
17022
- const candidates = [join36(here, "healthWorkerThread.js"), join36(here, "healthWorkerThread.ts")];
17011
+ const candidates = [join35(here, "healthWorkerThread.js"), join35(here, "healthWorkerThread.ts")];
17023
17012
  for (const candidate of candidates) {
17024
17013
  if (existsSync23(candidate)) return candidate;
17025
17014
  }
@@ -17157,6 +17146,7 @@ function buildRenderJobConfig(input, outputPath, log) {
17157
17146
  outputResolution: input.outputResolution,
17158
17147
  outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
17159
17148
  videoFrameFormat: input.videoFrameFormat,
17149
+ pageMotionBlur: input.pageMotionBlur,
17160
17150
  hdrMode: toRenderHdrMode(input.outputDynamicRange)
17161
17151
  }
17162
17152
  });
@@ -17232,8 +17222,8 @@ async function resolveInlineRenderHtml(body) {
17232
17222
  }
17233
17223
  function materializeInlineProject(html, options) {
17234
17224
  const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir3();
17235
- const tempProjectDir = mkdtempSync5(join37(tempRoot, "producer-project-"));
17236
- writeFileSync9(join37(tempProjectDir, "index.html"), html, "utf-8");
17225
+ const tempProjectDir = mkdtempSync5(join36(tempRoot, "producer-project-"));
17226
+ writeFileSync9(join36(tempProjectDir, "index.html"), html, "utf-8");
17237
17227
  return {
17238
17228
  prepared: {
17239
17229
  input: { projectDir: tempProjectDir, ...options },
@@ -17244,7 +17234,11 @@ function materializeInlineProject(html, options) {
17244
17234
  async function prepareRenderBody(body) {
17245
17235
  const overrideError = validateRenderOverrides(body);
17246
17236
  if (overrideError) return { error: overrideError };
17247
- const options = parseRenderOptions(body);
17237
+ const pageMotionBlur = body.pageMotionBlur;
17238
+ if (pageMotionBlur !== void 0 && pageMotionBlur !== "off") {
17239
+ return { error: "pageMotionBlur must be off when supplied" };
17240
+ }
17241
+ const options = { ...parseRenderOptions(body), pageMotionBlur };
17248
17242
  const project = prepareProjectDirectory(body.projectDir, options);
17249
17243
  if (project) return project;
17250
17244
  const source = await resolveInlineRenderHtml(body);
@@ -17290,7 +17284,7 @@ function createArtifactStore(ttlMs) {
17290
17284
  function cleanupTempDir(dir, log) {
17291
17285
  if (!dir) return;
17292
17286
  try {
17293
- rmSync12(dir, { recursive: true, force: true });
17287
+ rmSync13(dir, { recursive: true, force: true });
17294
17288
  } catch (error) {
17295
17289
  log.warn("Failed to cleanup temp project dir", {
17296
17290
  cleanupProjectDir: dir,
@@ -17699,12 +17693,12 @@ import {
17699
17693
  readFileSync as readFileSync13,
17700
17694
  readdirSync as readdirSync9,
17701
17695
  renameSync as renameSync8,
17702
- rmSync as rmSync14,
17696
+ rmSync as rmSync15,
17703
17697
  statSync as statSync10,
17704
17698
  writeFileSync as writeFileSync11
17705
17699
  } from "node:fs";
17706
17700
  import { tmpdir as tmpdir4 } from "node:os";
17707
- import { dirname as dirname16, isAbsolute as isAbsolute5, join as join40, relative as relative7, resolve as resolve11, sep as sep4 } from "node:path";
17701
+ import { dirname as dirname16, isAbsolute as isAbsolute5, join as join39, relative as relative7, resolve as resolve11, sep as sep4 } from "node:path";
17708
17702
  import { DISTRIBUTED_FPS as DISTRIBUTED_FPS2, isDistributedFps as isDistributedFps3 } from "@hyperframes/core";
17709
17703
  import { createFrameLookupTable as createFrameLookupTable4 } from "@hyperframes/engine";
17710
17704
 
@@ -17716,11 +17710,11 @@ import {
17716
17710
  mkdirSync as mkdirSync15,
17717
17711
  mkdtempSync as mkdtempSync6,
17718
17712
  renameSync as renameSync7,
17719
- rmSync as rmSync13,
17713
+ rmSync as rmSync14,
17720
17714
  statSync as statSync9,
17721
17715
  writeFileSync as writeFileSync10
17722
17716
  } from "node:fs";
17723
- import { dirname as dirname15, join as join39 } from "node:path";
17717
+ import { dirname as dirname15, join as join38 } from "node:path";
17724
17718
 
17725
17719
  // src/services/distributed/planV2Errors.ts
17726
17720
  var PLAN_V2_INTEGRITY_UNRECOVERABLE = "PLAN_V2_INTEGRITY_UNRECOVERABLE";
@@ -17735,7 +17729,7 @@ var PlanV2IntegrityError = class extends Error {
17735
17729
  };
17736
17730
 
17737
17731
  // src/services/distributed/planV2Layout.ts
17738
- import { join as join38 } from "node:path";
17732
+ import { join as join37 } from "node:path";
17739
17733
  var SHA256_PATTERN = /^[0-9a-f]{64}$/;
17740
17734
  function assertPlanV2Sha256(value, field) {
17741
17735
  if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
@@ -17745,7 +17739,7 @@ function assertPlanV2Sha256(value, field) {
17745
17739
  }
17746
17740
  function planV2BlobPath(root, sha256) {
17747
17741
  const digest = assertPlanV2Sha256(sha256, "artifact sha256");
17748
- return join38(root, "artifacts", "sha256", digest.slice(0, 2), digest);
17742
+ return join37(root, "artifacts", "sha256", digest.slice(0, 2), digest);
17749
17743
  }
17750
17744
 
17751
17745
  // src/services/distributed/planV2Publisher.ts
@@ -17786,7 +17780,7 @@ var LocalPlanV2ArtifactPublisher = class {
17786
17780
  this.destinationDir = destinationDir;
17787
17781
  this.#linkFile = options.linkFile ?? linkSync2;
17788
17782
  mkdirSync15(dirname15(destinationDir), { recursive: true });
17789
- this.temporaryDir = mkdtempSync6(join39(dirname15(destinationDir), ".plan-v2-publish-"));
17783
+ this.temporaryDir = mkdtempSync6(join38(dirname15(destinationDir), ".plan-v2-publish-"));
17790
17784
  }
17791
17785
  async putBlob(blob) {
17792
17786
  const digest = assertPlanV2Sha256(blob.sha256, "published blob sha256");
@@ -17799,8 +17793,8 @@ var LocalPlanV2ArtifactPublisher = class {
17799
17793
  const destinationPath = planV2BlobPath(this.temporaryDir, digest);
17800
17794
  if (existsSync25(destinationPath)) return;
17801
17795
  mkdirSync15(dirname15(destinationPath), { recursive: true });
17802
- const stagingDir = mkdtempSync6(join39(dirname15(destinationPath), ".plan-v2-blob-"));
17803
- const temporaryPath = join39(stagingDir, "blob");
17796
+ const stagingDir = mkdtempSync6(join38(dirname15(destinationPath), ".plan-v2-blob-"));
17797
+ const temporaryPath = join38(stagingDir, "blob");
17804
17798
  try {
17805
17799
  try {
17806
17800
  this.#linkFile(blob.sourcePath, temporaryPath);
@@ -17810,7 +17804,7 @@ var LocalPlanV2ArtifactPublisher = class {
17810
17804
  }
17811
17805
  renameSync7(temporaryPath, destinationPath);
17812
17806
  } finally {
17813
- rmSync13(stagingDir, { recursive: true, force: true });
17807
+ rmSync14(stagingDir, { recursive: true, force: true });
17814
17808
  }
17815
17809
  }
17816
17810
  async commitManifest(manifestBytes) {
@@ -17821,13 +17815,13 @@ var LocalPlanV2ArtifactPublisher = class {
17821
17815
  );
17822
17816
  }
17823
17817
  }
17824
- writeFileSync10(join39(this.temporaryDir, "plan.json"), manifestBytes, "utf-8");
17818
+ writeFileSync10(join38(this.temporaryDir, "plan.json"), manifestBytes, "utf-8");
17825
17819
  renameSync7(this.temporaryDir, this.destinationDir);
17826
17820
  this.#committed = true;
17827
17821
  }
17828
17822
  async abort() {
17829
17823
  if (!this.#committed) {
17830
- rmSync13(this.temporaryDir, { recursive: true, force: true });
17824
+ rmSync14(this.temporaryDir, { recursive: true, force: true });
17831
17825
  }
17832
17826
  }
17833
17827
  };
@@ -17866,7 +17860,7 @@ function listFiles(root) {
17866
17860
  const rootResolved = resolve11(root);
17867
17861
  function walk(dir) {
17868
17862
  for (const entry of readdirSync9(dir, { withFileTypes: true })) {
17869
- const absolutePath = join40(dir, entry.name);
17863
+ const absolutePath = join39(dir, entry.name);
17870
17864
  if (entry.isDirectory()) {
17871
17865
  walk(absolutePath);
17872
17866
  } else if (entry.isFile()) {
@@ -17893,13 +17887,13 @@ function assertValidExtractionCacheCompleteSentinel(path) {
17893
17887
  }
17894
17888
  }
17895
17889
  function validateExtractionCacheCompleteSentinels(executionPlanDir) {
17896
- const videoRoot = join40(executionPlanDir, "video-frames");
17890
+ const videoRoot = join39(executionPlanDir, "video-frames");
17897
17891
  if (!existsSync26(videoRoot)) return;
17898
17892
  for (const videoEntry of readdirSync9(videoRoot, { withFileTypes: true })) {
17899
17893
  if (!videoEntry.isDirectory()) continue;
17900
- const videoDir = join40(videoRoot, videoEntry.name);
17894
+ const videoDir = join39(videoRoot, videoEntry.name);
17901
17895
  if (readdirSync9(videoDir).includes(EXTRACTION_CACHE_COMPLETE_SENTINEL)) {
17902
- const sentinelPath = join40(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL);
17896
+ const sentinelPath = join39(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL);
17903
17897
  assertValidExtractionCacheCompleteSentinel(sentinelPath);
17904
17898
  }
17905
17899
  }
@@ -17946,7 +17940,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
17946
17940
  if (video.deferredRange) {
17947
17941
  const framePaths2 = /* @__PURE__ */ new Map();
17948
17942
  for (let index = 0; index < video.totalFrames; index++) {
17949
- framePaths2.set(index, join40(outputDir, String(index)));
17943
+ framePaths2.set(index, join39(outputDir, String(index)));
17950
17944
  }
17951
17945
  return { ...video, outputDir, framePaths: framePaths2, ownedByLookup: false };
17952
17946
  }
@@ -17954,7 +17948,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
17954
17948
  const framePaths = /* @__PURE__ */ new Map();
17955
17949
  for (const frameName of frameNames) {
17956
17950
  if (frameName === EXTRACTION_CACHE_COMPLETE_SENTINEL) {
17957
- assertValidExtractionCacheCompleteSentinel(join40(outputDir, frameName));
17951
+ assertValidExtractionCacheCompleteSentinel(join39(outputDir, frameName));
17958
17952
  continue;
17959
17953
  }
17960
17954
  const match = /(\d+)(?=\.[^.]+$)/.exec(frameName);
@@ -17973,7 +17967,7 @@ function listVideoFramePaths(executionPlanDir, videos) {
17973
17967
  `duplicate extracted frame index ${frameIndex} in ${outputDir}`
17974
17968
  );
17975
17969
  }
17976
- framePaths.set(frameIndex, join40(outputDir, frameName));
17970
+ framePaths.set(frameIndex, join39(outputDir, frameName));
17977
17971
  }
17978
17972
  return {
17979
17973
  ...video,
@@ -17994,7 +17988,7 @@ function parsePlanVideosJson2(value) {
17994
17988
  }
17995
17989
  }
17996
17990
  function materializeExtractedVideoDirectories(planDir) {
17997
- const videosPath = join40(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
17991
+ const videosPath = join39(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
17998
17992
  if (!existsSync26(videosPath)) return;
17999
17993
  const videos = parsePlanVideosJson2(readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH));
18000
17994
  for (const video of videos.extracted) {
@@ -18023,13 +18017,13 @@ function parseChunkSlices(value) {
18023
18017
  });
18024
18018
  }
18025
18019
  function buildVideoChunkDependencies(executionPlanDir, dimensions) {
18026
- const videoRoot = join40(executionPlanDir, "video-frames");
18020
+ const videoRoot = join39(executionPlanDir, "video-frames");
18027
18021
  const hasExtractedFrames = existsSync26(videoRoot) && listFiles(videoRoot).length > 0;
18028
- const videosPath = join40(executionPlanDir, PLAN_VIDEOS_META_RELATIVE_PATH);
18022
+ const videosPath = join39(executionPlanDir, PLAN_VIDEOS_META_RELATIVE_PATH);
18029
18023
  if (!existsSync26(videosPath)) {
18030
18024
  return hasExtractedFrames ? { mode: "full-source-pack", dependencies: null } : { mode: "exact-rendered-frames", dependencies: /* @__PURE__ */ new Map() };
18031
18025
  }
18032
- const chunksPath = join40(executionPlanDir, "meta", "chunks.json");
18026
+ const chunksPath = join39(executionPlanDir, "meta", "chunks.json");
18033
18027
  const videos = readJsonFile(videosPath, PLAN_VIDEOS_META_RELATIVE_PATH);
18034
18028
  const chunks = readJsonFile(chunksPath, "meta/chunks.json");
18035
18029
  const parsedVideos = parsePlanVideosJson2(videos);
@@ -18085,13 +18079,13 @@ function computeManifestHash(manifest) {
18085
18079
  function writeBlob(sourcePath, destinationPath) {
18086
18080
  if (existsSync26(destinationPath)) return;
18087
18081
  mkdirSync16(dirname16(destinationPath), { recursive: true });
18088
- const temporaryDir = mkdtempSync7(join40(dirname16(destinationPath), ".plan-v2-blob-"));
18089
- const temporaryPath = join40(temporaryDir, "blob");
18082
+ const temporaryDir = mkdtempSync7(join39(dirname16(destinationPath), ".plan-v2-blob-"));
18083
+ const temporaryPath = join39(temporaryDir, "blob");
18090
18084
  try {
18091
18085
  copyFileSync7(sourcePath, temporaryPath);
18092
18086
  renameSync8(temporaryPath, destinationPath);
18093
18087
  } finally {
18094
- rmSync14(temporaryDir, { recursive: true, force: true });
18088
+ rmSync15(temporaryDir, { recursive: true, force: true });
18095
18089
  }
18096
18090
  }
18097
18091
  function publishBlobFor(absolutePath, digests) {
@@ -18122,7 +18116,7 @@ async function putTasks(tasks) {
18122
18116
  if (failure) throw failure.reason;
18123
18117
  }
18124
18118
  function buildPlanV2Publication(executionPlanDir, digests) {
18125
- const executionPlanPath = join40(executionPlanDir, "plan.json");
18119
+ const executionPlanPath = join39(executionPlanDir, "plan.json");
18126
18120
  if (!existsSync26(executionPlanPath)) {
18127
18121
  throw new PlanV2IntegrityError(`execution plan is missing plan.json: ${executionPlanPath}`);
18128
18122
  }
@@ -18150,7 +18144,7 @@ function buildPlanV2Publication(executionPlanDir, digests) {
18150
18144
  }
18151
18145
  validateExtractionCacheCompleteSentinels(executionPlanDir);
18152
18146
  const videoDependencyPlan = buildVideoChunkDependencies(executionPlanDir, dimensions);
18153
- const encoder = readJsonFile(join40(executionPlanDir, "meta", "encoder.json"), "meta/encoder.json");
18147
+ const encoder = readJsonFile(join39(executionPlanDir, "meta", "encoder.json"), "meta/encoder.json");
18154
18148
  const files = listFiles(executionPlanDir);
18155
18149
  const chunkAudio = !isRecord3(encoder) || encoder.chunksCarryAudio !== true ? "none" : files.some((file) => chunkAudioSliceTarget(file.path) !== null) ? "sliced" : "whole";
18156
18150
  for (const file of files) {
@@ -18211,20 +18205,20 @@ function createPlanV2FromExecutionPlan(executionPlanDir, planV2Dir) {
18211
18205
  throw new PlanV2IntegrityError("a local plan v2 directory cannot reference remote files");
18212
18206
  }
18213
18207
  mkdirSync16(dirname16(planV2Dir), { recursive: true });
18214
- const tempDir = mkdtempSync7(join40(dirname16(planV2Dir), ".plan-v2-build-"));
18208
+ const tempDir = mkdtempSync7(join39(dirname16(planV2Dir), ".plan-v2-build-"));
18215
18209
  try {
18216
18210
  for (const blob of publication.blobs) {
18217
18211
  writeBlob(blob.sourcePath, planV2BlobPath(tempDir, blob.sha256));
18218
18212
  }
18219
18213
  writeFileSync11(
18220
- join40(tempDir, "plan.json"),
18214
+ join39(tempDir, "plan.json"),
18221
18215
  canonicalJsonStringify(publication.manifest),
18222
18216
  "utf-8"
18223
18217
  );
18224
18218
  renameSync8(tempDir, planV2Dir);
18225
18219
  return resultFromManifest(planV2Dir, publication.manifest);
18226
18220
  } catch (error) {
18227
- rmSync14(tempDir, { recursive: true, force: true });
18221
+ rmSync15(tempDir, { recursive: true, force: true });
18228
18222
  throw error;
18229
18223
  }
18230
18224
  }
@@ -18264,7 +18258,7 @@ async function publishPlanV2FromV1(executionPlanDir, publisher) {
18264
18258
  return publishPlanV2FromExecutionPlan(executionPlanDir, publisher);
18265
18259
  }
18266
18260
  async function planV2WithPublisher(projectDir, config, publisher, options = {}) {
18267
- const stagingRoot = mkdtempSync7(join40(options.stagingParentDir ?? tmpdir4(), ".execution-plan-"));
18261
+ const stagingRoot = mkdtempSync7(join39(options.stagingParentDir ?? tmpdir4(), ".execution-plan-"));
18268
18262
  const digests = /* @__PURE__ */ new Map();
18269
18263
  try {
18270
18264
  await buildLocalExecutionPlan(projectDir, config, stagingRoot, {
@@ -18275,7 +18269,7 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
18275
18269
  // artifactTargets ships every compiled/ file to all chunks, so each is in the manifest.
18276
18270
  whileAudioMixes: () => putBlobs(
18277
18271
  publisher,
18278
- listFiles(join40(stagingRoot, "compiled")).map(
18272
+ listFiles(join39(stagingRoot, "compiled")).map(
18279
18273
  (file) => publishBlobFor(file.absolutePath, digests)
18280
18274
  )
18281
18275
  ),
@@ -18291,7 +18285,7 @@ async function planV2WithPublisher(projectDir, config, publisher, options = {})
18291
18285
  }
18292
18286
  throw error;
18293
18287
  } finally {
18294
- rmSync14(stagingRoot, { recursive: true, force: true });
18288
+ rmSync15(stagingRoot, { recursive: true, force: true });
18295
18289
  }
18296
18290
  }
18297
18291
  async function planV2(projectDir, config, planV2Dir) {
@@ -18307,7 +18301,7 @@ async function planV2(projectDir, config, planV2Dir) {
18307
18301
  function resultFromManifest(planV2Dir, manifest) {
18308
18302
  return {
18309
18303
  planDir: planV2Dir,
18310
- manifestPath: join40(planV2Dir, "plan.json"),
18304
+ manifestPath: join39(planV2Dir, "plan.json"),
18311
18305
  planProtocol: PLAN_PROTOCOL_V2,
18312
18306
  planHash: manifest.planHash,
18313
18307
  sourcePlanV1Hash: getPlanV2ExecutionPlanHash(manifest),
@@ -18443,7 +18437,7 @@ function parsePlanV2Manifest(value) {
18443
18437
  return parsed;
18444
18438
  }
18445
18439
  function readPlanV2Manifest(planV2Dir) {
18446
- const manifestPath = join40(planV2Dir, "plan.json");
18440
+ const manifestPath = join39(planV2Dir, "plan.json");
18447
18441
  if (!existsSync26(manifestPath)) {
18448
18442
  throw new PlanV2IntegrityError(`missing v2 manifest: ${manifestPath}`);
18449
18443
  }
@@ -18489,16 +18483,16 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
18489
18483
  sourcePath: verifyBlob(planV2Dir, artifact)
18490
18484
  }));
18491
18485
  mkdirSync16(dirname16(destinationDir), { recursive: true });
18492
- const tempDir = mkdtempSync7(join40(dirname16(destinationDir), ".plan-v2-materialize-"));
18486
+ const tempDir = mkdtempSync7(join39(dirname16(destinationDir), ".plan-v2-materialize-"));
18493
18487
  try {
18494
18488
  for (const { artifact, sourcePath } of verified) {
18495
- const destinationPath = join40(tempDir, ...artifact.path.split("/"));
18489
+ const destinationPath = join39(tempDir, ...artifact.path.split("/"));
18496
18490
  mkdirSync16(dirname16(destinationPath), { recursive: true });
18497
18491
  copyFileSync7(sourcePath, destinationPath);
18498
18492
  }
18499
18493
  if (target.role === "chunk") materializeExtractedVideoDirectories(tempDir);
18500
18494
  writeFileSync11(
18501
- join40(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
18495
+ join39(tempDir, PLAN_V2_MATERIALIZATION_MARKER),
18502
18496
  canonicalJsonStringify({
18503
18497
  manifest,
18504
18498
  target,
@@ -18508,7 +18502,7 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
18508
18502
  );
18509
18503
  renameSync8(tempDir, destinationDir);
18510
18504
  } catch (error) {
18511
- rmSync14(tempDir, { recursive: true, force: true });
18505
+ rmSync15(tempDir, { recursive: true, force: true });
18512
18506
  throw error;
18513
18507
  }
18514
18508
  return {
@@ -18521,7 +18515,7 @@ function materializePlanV2Target(planV2Dir, target, destinationDir, options = {}
18521
18515
  // Join the artifact's OWN name, not the current constant: a plan written
18522
18516
  // before the container change carries the legacy name, and materializing it
18523
18517
  // under the new one would point at a file that was never written.
18524
- audioPath: target.role === "assembler" ? audioArtifact ? join40(destinationDir, audioArtifact.path) : null : null
18518
+ audioPath: target.role === "assembler" ? audioArtifact ? join39(destinationDir, audioArtifact.path) : null : null
18525
18519
  };
18526
18520
  }
18527
18521
  function assertRemoteSources(remoteSources, artifacts) {
@@ -18540,7 +18534,7 @@ function validatePlanV2MaterializedTarget(planDir, expectedTarget) {
18540
18534
  return readValidatedPlanV2Materialization(planDir, expectedTarget)?.manifest ?? null;
18541
18535
  }
18542
18536
  function readValidatedPlanV2Materialization(planDir, expectedTarget) {
18543
- const markerPath = join40(planDir, PLAN_V2_MATERIALIZATION_MARKER);
18537
+ const markerPath = join39(planDir, PLAN_V2_MATERIALIZATION_MARKER);
18544
18538
  if (!existsSync26(markerPath)) return null;
18545
18539
  const marker = readJsonFile(markerPath, "materialization marker");
18546
18540
  if (!isRecord3(marker) || !isRecord3(marker.manifest) || !isRecord3(marker.target)) {
@@ -18558,7 +18552,7 @@ function readValidatedPlanV2Materialization(planDir, expectedTarget) {
18558
18552
  assertRemoteSources(remoteSources, required);
18559
18553
  for (const artifact of required) {
18560
18554
  if (Object.hasOwn(remoteSources, artifact.path)) continue;
18561
- const path = join40(planDir, ...artifact.path.split("/"));
18555
+ const path = join39(planDir, ...artifact.path.split("/"));
18562
18556
  if (!existsSync26(path) || statSync10(path).size !== artifact.sizeBytes) {
18563
18557
  throw new PlanV2IntegrityError(
18564
18558
  `materialized artifact missing or truncated: ${artifact.path}`
@@ -18572,9 +18566,9 @@ function readValidatedPlanV2Materialization(planDir, expectedTarget) {
18572
18566
  }
18573
18567
 
18574
18568
  // src/services/distributed/planV2Execution.ts
18575
- import { mkdtempSync as mkdtempSync9, rmSync as rmSync17 } from "node:fs";
18569
+ import { mkdtempSync as mkdtempSync9, rmSync as rmSync18 } from "node:fs";
18576
18570
  import { tmpdir as tmpdir5 } from "node:os";
18577
- import { join as join43 } from "node:path";
18571
+ import { join as join42 } from "node:path";
18578
18572
 
18579
18573
  // src/services/distributed/assemble.ts
18580
18574
  import {
@@ -18585,11 +18579,11 @@ import {
18585
18579
  readFileSync as readFileSync14,
18586
18580
  readdirSync as readdirSync10,
18587
18581
  renameSync as renameSync9,
18588
- rmSync as rmSync15,
18582
+ rmSync as rmSync16,
18589
18583
  statSync as statSync11,
18590
18584
  writeFileSync as writeFileSync12
18591
18585
  } from "node:fs";
18592
- import { dirname as dirname17, join as join41 } from "node:path";
18586
+ import { dirname as dirname17, join as join40 } from "node:path";
18593
18587
  import {
18594
18588
  appendRenderProvenanceArgs as appendRenderProvenanceArgs2,
18595
18589
  describeFfmpegFailure as describeFfmpegFailure4,
@@ -18597,7 +18591,8 @@ import {
18597
18591
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME4,
18598
18592
  muxVideoWithAudio as muxVideoWithAudio2,
18599
18593
  resolveConfig as resolveConfig6,
18600
- runFfmpeg as runFfmpeg7
18594
+ runFfmpeg as runFfmpeg7,
18595
+ appendSoftwareEncoderThreadsArg
18601
18596
  } from "@hyperframes/engine";
18602
18597
  import { fpsToFfmpegArg as fpsToFfmpegArg3 } from "@hyperframes/core";
18603
18598
  function cfrRateControlArgs(locked) {
@@ -18651,7 +18646,7 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
18651
18646
  };
18652
18647
  try {
18653
18648
  const videoSources = cfrEncoder ? [await reencodeCfr(ctx, chunkPaths, cfrEncoder)] : chunkPaths;
18654
- const finalPath = join41(workDir, `output.${format}`);
18649
+ const finalPath = join40(workDir, `output.${format}`);
18655
18650
  const audio = audioPath !== null && existsSync27(audioPath) ? audioPath : null;
18656
18651
  await writeJoined(ctx, videoSources, audio, finalPath);
18657
18652
  renameSync9(finalPath, outputPath);
@@ -18662,7 +18657,7 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
18662
18657
  });
18663
18658
  } finally {
18664
18659
  try {
18665
- rmSync15(workDir, { recursive: true, force: true });
18660
+ rmSync16(workDir, { recursive: true, force: true });
18666
18661
  } catch (err) {
18667
18662
  log.warn("[assemble] failed to remove work dir", {
18668
18663
  workDir,
@@ -18679,8 +18674,8 @@ async function assemble(planDir, chunkOutputs, audioPath, outputPath, options) {
18679
18674
  };
18680
18675
  }
18681
18676
  function readAssemblePlan(planDir, chunkPaths) {
18682
- const planJsonPath = join41(planDir, "plan.json");
18683
- const chunksJsonPath = join41(planDir, "meta", "chunks.json");
18677
+ const planJsonPath = join40(planDir, "plan.json");
18678
+ const chunksJsonPath = join40(planDir, "meta", "chunks.json");
18684
18679
  if (!existsSync27(planJsonPath)) {
18685
18680
  throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
18686
18681
  }
@@ -18711,10 +18706,9 @@ function readAssemblePlan(planDir, chunkPaths) {
18711
18706
  function videoInputArgs(ctx, videoSources) {
18712
18707
  return videoSources.length === 1 ? ["-i", videoSources[0]] : concatDemuxerInput(videoSources, ctx.workDir, ctx.fpsArg);
18713
18708
  }
18714
- async function reencodeCfr(ctx, chunkPaths, encoder) {
18715
- const cfrOutputPath = join41(ctx.workDir, `cfr.${ctx.format}`);
18716
- const cfrArgs = [
18717
- ...videoInputArgs(ctx, chunkPaths),
18709
+ function cfrEncodeArgs(inputArgs, encoder, fpsArg) {
18710
+ const args = [
18711
+ ...inputArgs,
18718
18712
  "-c:v",
18719
18713
  "libx264",
18720
18714
  ...cfrRateControlArgs(encoder),
@@ -18723,8 +18717,14 @@ async function reencodeCfr(ctx, chunkPaths, encoder) {
18723
18717
  "-fps_mode",
18724
18718
  "cfr",
18725
18719
  "-r",
18726
- ctx.fpsArg
18720
+ fpsArg
18727
18721
  ];
18722
+ appendSoftwareEncoderThreadsArg(args);
18723
+ return args;
18724
+ }
18725
+ async function reencodeCfr(ctx, chunkPaths, encoder) {
18726
+ const cfrOutputPath = join40(ctx.workDir, `cfr.${ctx.format}`);
18727
+ const cfrArgs = cfrEncodeArgs(videoInputArgs(ctx, chunkPaths), encoder, ctx.fpsArg);
18728
18728
  appendRenderProvenanceArgs2(cfrArgs, cfrOutputPath);
18729
18729
  cfrArgs.push("-y", cfrOutputPath);
18730
18730
  await ctx.timed(
@@ -18750,7 +18750,7 @@ async function writeJoined(ctx, videoSources, audioPath, finalPath) {
18750
18750
  );
18751
18751
  return;
18752
18752
  }
18753
- const paddedAudioPath = join41(ctx.workDir, "audio-padded.m4a");
18753
+ const paddedAudioPath = join40(ctx.workDir, "audio-padded.m4a");
18754
18754
  const padTrimResult = await ctx.timed(
18755
18755
  "audioMs",
18756
18756
  () => padOrTrimAudioToVideoFrameCount({
@@ -18790,7 +18790,7 @@ async function writeJoined(ctx, videoSources, audioPath, finalPath) {
18790
18790
  }
18791
18791
  }
18792
18792
  function concatDemuxerInput(chunkPaths, workDir, fpsArg) {
18793
- const listPath = join41(workDir, "concat-list.txt");
18793
+ const listPath = join40(workDir, "concat-list.txt");
18794
18794
  const body = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
18795
18795
  writeFileSync12(listPath, `${body}
18796
18796
  `, "utf-8");
@@ -18818,6 +18818,7 @@ async function runAssembleFfmpeg(args, step, signal, timeout) {
18818
18818
  function chunksAreStitchedMp4(planDir, cfr) {
18819
18819
  const { chunksCarryAudio, separateAudio } = lockedAudio(planDir);
18820
18820
  if (!chunksCarryAudio && !separateAudio) return false;
18821
+ if (planFormat(planDir) !== "mp4") return false;
18821
18822
  if (cfr) {
18822
18823
  throw new Error("[assemble] cfr=true cannot re-encode a plan whose mp4 chunks are stitched");
18823
18824
  }
@@ -18826,8 +18827,12 @@ function chunksAreStitchedMp4(planDir, cfr) {
18826
18827
  function stitchesSeparateAudio(planDir) {
18827
18828
  return lockedAudio(planDir).separateAudio === true;
18828
18829
  }
18830
+ function planFormat(planDir) {
18831
+ const plan2 = JSON.parse(readFileSync14(join40(planDir, "plan.json"), "utf-8"));
18832
+ return plan2.dimensions.format;
18833
+ }
18829
18834
  function lockedAudio(planDir) {
18830
- const encoderJsonPath = join41(planDir, "meta", "encoder.json");
18835
+ const encoderJsonPath = join40(planDir, "meta", "encoder.json");
18831
18836
  if (!existsSync27(encoderJsonPath)) return {};
18832
18837
  return JSON.parse(readFileSync14(encoderJsonPath, "utf-8"));
18833
18838
  }
@@ -18837,7 +18842,7 @@ function readCfrEncoder(planDir, format) {
18837
18842
  `[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.`
18838
18843
  );
18839
18844
  }
18840
- const encoderJsonPath = join41(planDir, "meta", "encoder.json");
18845
+ const encoderJsonPath = join40(planDir, "meta", "encoder.json");
18841
18846
  if (!existsSync27(encoderJsonPath)) {
18842
18847
  throw new Error(`[assemble] planDir missing meta/encoder.json: ${encoderJsonPath}`);
18843
18848
  }
@@ -18850,7 +18855,7 @@ function readCfrEncoder(planDir, format) {
18850
18855
  return encoderJson;
18851
18856
  }
18852
18857
  function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, startTimeMs) {
18853
- if (existsSync27(outputPath)) rmSync15(outputPath, { recursive: true, force: true });
18858
+ if (existsSync27(outputPath)) rmSync16(outputPath, { recursive: true, force: true });
18854
18859
  mkdirSync17(outputPath, { recursive: true });
18855
18860
  let globalIdx = 0;
18856
18861
  for (const chunkDir of chunkPaths) {
@@ -18864,8 +18869,8 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
18864
18869
  throw new Error(`[assemble] png-sequence chunk has no frames: ${chunkDir}`);
18865
18870
  }
18866
18871
  for (const frame of frames) {
18867
- const dst = join41(outputPath, formatExportFrameName(globalIdx, "png"));
18868
- cpSync3(join41(chunkDir, frame), dst);
18872
+ const dst = join40(outputPath, formatExportFrameName(globalIdx, "png"));
18873
+ cpSync3(join40(chunkDir, frame), dst);
18869
18874
  globalIdx += 1;
18870
18875
  }
18871
18876
  }
@@ -18875,13 +18880,13 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
18875
18880
  );
18876
18881
  }
18877
18882
  if (audioPath !== null && existsSync27(audioPath)) {
18878
- const sidecar = join41(outputPath, MIXED_AUDIO_FILENAME4);
18883
+ const sidecar = join40(outputPath, MIXED_AUDIO_FILENAME4);
18879
18884
  cpSync3(audioPath, sidecar);
18880
18885
  }
18881
18886
  let fileSize = 0;
18882
18887
  for (const name of readdirSync10(outputPath)) {
18883
18888
  try {
18884
- fileSize += statSync11(join41(outputPath, name)).size;
18889
+ fileSize += statSync11(join40(outputPath, name)).size;
18885
18890
  } catch {
18886
18891
  }
18887
18892
  }
@@ -18895,8 +18900,8 @@ function mergePngFrameDirs(chunkPaths, outputPath, totalFrames, audioPath, start
18895
18900
 
18896
18901
  // src/services/distributed/renderChunk.ts
18897
18902
  import { randomBytes } from "node:crypto";
18898
- import { existsSync as existsSync28, mkdirSync as mkdirSync18, readFileSync as readFileSync15, readdirSync as readdirSync11, rmSync as rmSync16, writeFileSync as writeFileSync13 } from "node:fs";
18899
- import { join as join42 } from "node:path";
18903
+ import { existsSync as existsSync28, mkdirSync as mkdirSync18, readFileSync as readFileSync15, readdirSync as readdirSync11, rmSync as rmSync17, writeFileSync as writeFileSync13 } from "node:fs";
18904
+ import { join as join41 } from "node:path";
18900
18905
  import {
18901
18906
  assertSwiftShader,
18902
18907
  BROWSER_GPU_NOT_SOFTWARE as BROWSER_GPU_NOT_SOFTWARE4,
@@ -18998,7 +19003,7 @@ function hashChunkOutput(outputPath, kind) {
18998
19003
  if (kind === "file") return sha256Hex(readFileSync15(outputPath));
18999
19004
  const entries = readdirSync11(outputPath).filter((name) => /\.(png|jpg|jpeg)$/i.test(name)).sort();
19000
19005
  const lines = entries.map(
19001
- (name) => `${name}\0${sha256Hex(readFileSync15(join42(outputPath, name)))}`
19006
+ (name) => `${name}\0${sha256Hex(readFileSync15(join41(outputPath, name)))}`
19002
19007
  );
19003
19008
  return sha256Hex(lines.join("\0"));
19004
19009
  }
@@ -19015,9 +19020,9 @@ function resolveLockedVp9CpuUsed(lockedEncoder) {
19015
19020
  async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19016
19021
  const start = Date.now();
19017
19022
  const log = defaultLogger;
19018
- const planJsonPath = join42(planDir, "plan.json");
19019
- const encoderJsonPath = join42(planDir, "meta", "encoder.json");
19020
- const chunksJsonPath = join42(planDir, "meta", "chunks.json");
19023
+ const planJsonPath = join41(planDir, "plan.json");
19024
+ const encoderJsonPath = join41(planDir, "meta", "encoder.json");
19025
+ const chunksJsonPath = join41(planDir, "meta", "chunks.json");
19021
19026
  if (!existsSync28(planJsonPath)) {
19022
19027
  throw new RenderChunkValidationError(
19023
19028
  MISSING_PLAN_ARTIFACT,
@@ -19043,7 +19048,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19043
19048
  }
19044
19049
  const encoder = JSON.parse(readFileSync15(encoderJsonPath, "utf-8"));
19045
19050
  const chunks = JSON.parse(readFileSync15(chunksJsonPath, "utf-8"));
19046
- const videosJsonPath = join42(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
19051
+ const videosJsonPath = join41(planDir, PLAN_VIDEOS_META_RELATIVE_PATH);
19047
19052
  let planVideos = null;
19048
19053
  if (existsSync28(videosJsonPath)) {
19049
19054
  try {
@@ -19075,7 +19080,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19075
19080
  }
19076
19081
  const range = resolveChunkFrameRange(slice, chunkIndex, options?.frameRange);
19077
19082
  const framesInChunk = range.endFrame - range.startFrame;
19078
- const compiledDir = join42(planDir, "compiled");
19083
+ const compiledDir = join41(planDir, "compiled");
19079
19084
  if (!existsSync28(compiledDir)) {
19080
19085
  throw new RenderChunkValidationError(
19081
19086
  MISSING_PLAN_ARTIFACT,
@@ -19133,7 +19138,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19133
19138
  };
19134
19139
  const workDir = `${outputChunkPath}.work.${process.pid}.${randomBytes(4).toString("hex")}`;
19135
19140
  mkdirSync18(workDir, { recursive: true });
19136
- const framesDir = join42(workDir, "captured-frames");
19141
+ const framesDir = join41(workDir, "captured-frames");
19137
19142
  mkdirSync18(framesDir, { recursive: true });
19138
19143
  const videoFramesDir = `${workDir}.video-frames`;
19139
19144
  const chunkFrameNumbers = Array.from({ length: framesInChunk }, (_, i) => range.startFrame + i);
@@ -19158,7 +19163,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19158
19163
  dimensions: plan2.dimensions,
19159
19164
  encoder,
19160
19165
  videoCount: planVideos?.videos.length ?? 0,
19161
- html: readFileSync15(join42(compiledDir, "index.html"), "utf-8"),
19166
+ html: readFileSync15(join41(compiledDir, "index.html"), "utf-8"),
19162
19167
  // The plan ships a deferred source only to chunks that show it; don't wait on the rest.
19163
19168
  skipReadinessVideoIds: deferredVideos.filter(
19164
19169
  (video) => !shownFrames.get(video.videoId)?.size || remoteVideoIds.has(video.videoId)
@@ -19334,7 +19339,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19334
19339
  return observedMode;
19335
19340
  },
19336
19341
  resetForScreenshotRetry: () => {
19337
- rmSync16(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
19342
+ rmSync17(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
19338
19343
  mkdirSync18(framesDir, { recursive: true });
19339
19344
  capturePerfs.length = 0;
19340
19345
  captureAttempts.length = 0;
@@ -19372,7 +19377,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19372
19377
  if (isPngSequence) {
19373
19378
  if (!existsSync28(outputChunkPath)) mkdirSync18(outputChunkPath, { recursive: true });
19374
19379
  } else {
19375
- const outDir = join42(outputChunkPath, "..");
19380
+ const outDir = join41(outputChunkPath, "..");
19376
19381
  if (!existsSync28(outDir)) mkdirSync18(outDir, { recursive: true });
19377
19382
  }
19378
19383
  const encodeStarted = Date.now();
@@ -19427,8 +19432,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19427
19432
  fps: { num: plan2.dimensions.fpsNum, den: plan2.dimensions.fpsDen },
19428
19433
  timeoutMs: cfg.ffmpegProcessTimeout
19429
19434
  });
19430
- rmSync16(videoOnlyPath, { force: true });
19431
- rmSync16(audioScratchPath, { force: true });
19435
+ rmSync17(videoOnlyPath, { force: true });
19436
+ rmSync17(audioScratchPath, { force: true });
19432
19437
  }
19433
19438
  encodeStageMs = Date.now() - encodeStarted;
19434
19439
  } finally {
@@ -19474,8 +19479,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19474
19479
  writeFileSync13(perfPath, `${JSON.stringify(perfPayload, null, 2)}
19475
19480
  `, "utf-8");
19476
19481
  try {
19477
- rmSync16(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
19478
- rmSync16(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
19482
+ rmSync17(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
19483
+ rmSync17(videoFramesDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
19479
19484
  } catch (err) {
19480
19485
  log.warn("[renderChunk] failed to remove work dir", {
19481
19486
  workDir,
@@ -19504,18 +19509,18 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19504
19509
 
19505
19510
  // src/services/distributed/planV2Execution.ts
19506
19511
  async function renderChunkV2(planV2Dir, chunkIndex, outputChunkPath, options) {
19507
- const workRoot = mkdtempSync9(join43(tmpdir5(), "hf-plan-v2-chunk-"));
19508
- const materializedPlanDir = join43(workRoot, "plan");
19512
+ const workRoot = mkdtempSync9(join42(tmpdir5(), "hf-plan-v2-chunk-"));
19513
+ const materializedPlanDir = join42(workRoot, "plan");
19509
19514
  try {
19510
19515
  materializePlanV2Target(planV2Dir, { role: "chunk", chunkIndex }, materializedPlanDir);
19511
19516
  return await renderChunk(materializedPlanDir, chunkIndex, outputChunkPath, options);
19512
19517
  } finally {
19513
- rmSync17(workRoot, { recursive: true, force: true });
19518
+ rmSync18(workRoot, { recursive: true, force: true });
19514
19519
  }
19515
19520
  }
19516
19521
  async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
19517
- const workRoot = mkdtempSync9(join43(tmpdir5(), "hf-plan-v2-assembler-"));
19518
- const materializedPlanDir = join43(workRoot, "plan");
19522
+ const workRoot = mkdtempSync9(join42(tmpdir5(), "hf-plan-v2-assembler-"));
19523
+ const materializedPlanDir = join42(workRoot, "plan");
19519
19524
  try {
19520
19525
  const materialized = materializePlanV2Target(
19521
19526
  planV2Dir,
@@ -19530,7 +19535,7 @@ async function assembleV2(planV2Dir, chunkPaths, outputPath, options) {
19530
19535
  options
19531
19536
  );
19532
19537
  } finally {
19533
- rmSync17(workRoot, { recursive: true, force: true });
19538
+ rmSync18(workRoot, { recursive: true, force: true });
19534
19539
  }
19535
19540
  }
19536
19541