@hyperframes/producer 0.8.185 → 0.8.188

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,10 @@ import {
7
7
  statSync as statSync8,
8
8
  mkdtempSync as mkdtempSync4,
9
9
  writeFileSync as writeFileSync6,
10
- rmSync as rmSync10,
10
+ rmSync as rmSync11,
11
11
  createReadStream as createReadStream3
12
12
  } from "node:fs";
13
- import { resolve as resolve9, dirname as dirname12, join as join30 } from "node:path";
13
+ import { resolve as resolve9, dirname as dirname12, join as join29 } from "node:path";
14
14
  import { tmpdir as tmpdir3 } from "node:os";
15
15
  import { parseArgs } from "node:util";
16
16
  import crypto from "node:crypto";
@@ -25,7 +25,7 @@ import {
25
25
  mkdirSync as mkdirSync9,
26
26
  readFileSync as readFileSync8,
27
27
  readdirSync as readdirSync5,
28
- rmSync as rmSync9,
28
+ rmSync as rmSync10,
29
29
  statSync as statSync6,
30
30
  writeFileSync as writeFileSync5,
31
31
  copyFileSync as copyFileSync4,
@@ -75,7 +75,7 @@ import {
75
75
  augmentProtocolTimeoutError,
76
76
  augmentPageNavigationTimeoutError
77
77
  } from "@hyperframes/engine";
78
- import { join as join26, dirname as dirname10, resolve as resolve7 } from "path";
78
+ import { join as join25, dirname as dirname10, resolve as resolve7 } from "path";
79
79
  import { totalmem as totalmem2 } from "node:os";
80
80
  import { randomUUID } from "crypto";
81
81
  import { fileURLToPath as fileURLToPath4 } from "url";
@@ -4355,6 +4355,7 @@ function computeSegmentPlanHash(input) {
4355
4355
  input.useGpu ? "gpu" : "cpu",
4356
4356
  `${input.outputWidth}x${input.outputHeight}`,
4357
4357
  input.motionBlur,
4358
+ input.pageMotionBlur ?? "",
4358
4359
  input.captureQuality === void 0 ? "" : String(input.captureQuality)
4359
4360
  ].join(" ");
4360
4361
  return createHash2("sha256").update(ordered).digest("hex").slice(0, 16);
@@ -6023,7 +6024,10 @@ function stripCompilerMountBootstrap(source) {
6023
6024
  ""
6024
6025
  );
6025
6026
  }
6026
- function detectRenderModeHints(html) {
6027
+ function runtimeRoot(document2) {
6028
+ return document2.querySelector('[data-composition-id][data-root="true"]') ?? document2.querySelector("[data-composition-id]");
6029
+ }
6030
+ function detectRenderModeHints(html, pageMotionBlur) {
6027
6031
  const reasons = [];
6028
6032
  const { document: document2 } = parseHTML4(html);
6029
6033
  if (document2.querySelector("canvas[layoutsubtree]")) {
@@ -6031,6 +6035,11 @@ function detectRenderModeHints(html) {
6031
6035
  code: "htmlInCanvas",
6032
6036
  message: "Detected html-in-canvas API (layoutsubtree canvas). Chrome does not support concurrent drawElementImage across multiple workers; render is pinned to a single worker."
6033
6037
  });
6038
+ } else if (pageMotionBlur !== "off" && runtimeRoot(document2)?.getAttribute("data-motion-blur") === "on") {
6039
+ reasons.push({
6040
+ code: "captureMotionBlur",
6041
+ message: 'Detected capture motion blur (data-motion-blur="on"), which draws each frame with html-in-canvas; routed through screenshot capture mode.'
6042
+ });
6034
6043
  }
6035
6044
  if (document2.querySelector("iframe")) {
6036
6045
  reasons.push({
@@ -7062,7 +7071,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
7062
7071
  /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
7063
7072
  "$1"
7064
7073
  );
7065
- const renderModeHints = detectRenderModeHints(sanitizedHtml);
7074
+ const renderModeHints = detectRenderModeHints(sanitizedHtml, options.pageMotionBlur);
7066
7075
  const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
7067
7076
  const usesThreeDTransforms = detectThreeDTransformUsage(sanitizedHtml);
7068
7077
  const usesMixBlendMode = detectMixBlendModeUsage(sanitizedHtml);
@@ -7204,7 +7213,9 @@ async function discoverMediaFromBrowser(page) {
7204
7213
  const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
7205
7214
  const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
7206
7215
  if (!id) return;
7207
- const src = htmlEl.currentSrc || htmlEl.src || htmlEl.getAttribute("src") || "";
7216
+ const ownSrc = htmlEl.getAttribute("src");
7217
+ const blankSrc = ownSrc !== null && ownSrc.trim() === "" && !htmlEl.querySelector("source[src]");
7218
+ const src = blankSrc ? "" : htmlEl.currentSrc || htmlEl.src || ownSrc || "";
7208
7219
  const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
7209
7220
  const endRaw = htmlEl.getAttribute("data-end");
7210
7221
  const durationRaw = htmlEl.getAttribute("data-duration");
@@ -7547,6 +7558,7 @@ async function runCompileStage(input) {
7547
7558
  abortSignal,
7548
7559
  variables: input.variables,
7549
7560
  urlByAbsentLocalPath: input.urlByAbsentLocalPath,
7561
+ pageMotionBlur: input.pageMotionBlur,
7550
7562
  animatedGifCacheDir: cfg.extractCacheDir ? join14(cfg.extractCacheDir, "animated-gif") : void 0,
7551
7563
  ffmpegProcessTimeout: cfg.ffmpegProcessTimeout
7552
7564
  });
@@ -7843,6 +7855,7 @@ async function runProbeStage(input) {
7843
7855
  variables: job.config.variables,
7844
7856
  deviceScaleFactor,
7845
7857
  motionBlur,
7858
+ pageMotionBlur: job.config.pageMotionBlur,
7846
7859
  requiresWebGpu: compositionRequiresWebGpu(compiled.html),
7847
7860
  subTimelineWaitMemo
7848
7861
  };
@@ -8669,771 +8682,1209 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
8669
8682
  }
8670
8683
 
8671
8684
  // src/services/render/stages/audioStage.ts
8672
- import { join as join17 } from "node:path";
8685
+ import { join as join18 } from "node:path";
8686
+ import { rmSync as rmSync8 } from "node:fs";
8673
8687
  import {
8674
8688
  MIXED_AUDIO_FILENAME,
8675
8689
  audioFailureDiagnostics,
8676
8690
  processCompositionAudio
8677
8691
  } from "@hyperframes/engine";
8678
- function triggerFirst(failures, trigger) {
8679
- return [trigger, ...(failures ?? []).filter((failure) => failure !== trigger)];
8692
+
8693
+ // src/services/render/audioPadTrim.ts
8694
+ import { spawn } from "node:child_process";
8695
+ import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync7 } from "node:fs";
8696
+ import { dirname as dirname7, join as join17 } from "node:path";
8697
+ import {
8698
+ DEFAULT_CONFIG,
8699
+ describeFfmpegFailure as describeFfmpegFailure2,
8700
+ encodeAac,
8701
+ extractAudioMetadata as extractAudioMetadata3,
8702
+ getFfprobeBinary,
8703
+ ManagedChildProcess,
8704
+ runFfmpeg as runFfmpeg4,
8705
+ trackChildProcess
8706
+ } from "@hyperframes/engine";
8707
+ import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
8708
+
8709
+ // src/services/render/audioTruePeak.ts
8710
+ import {
8711
+ aacSegmentCount,
8712
+ describeFfmpegFailure,
8713
+ extractAudioMetadata as extractAudioMetadata2,
8714
+ runFfmpeg as runFfmpeg3
8715
+ } from "@hyperframes/engine";
8716
+ var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
8717
+ function checkedDbfs(value) {
8718
+ if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
8719
+ throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
8720
+ }
8721
+ return value;
8680
8722
  }
8681
- async function runAudioStage(input) {
8682
- const {
8683
- projectDir,
8684
- workDir,
8685
- compiledDir,
8686
- duration,
8687
- ffmpegProcessTimeout,
8688
- audioGain,
8689
- audios,
8690
- masterFxChain,
8691
- urlByAbsentLocalPath,
8692
- keepEncoderInputAt,
8693
- abortSignal,
8694
- assertNotAborted,
8695
- log
8696
- } = input;
8697
- const stage3Start = Date.now();
8698
- const audioOutputPath = join17(workDir, MIXED_AUDIO_FILENAME);
8699
- let hasAudio = false;
8700
- let audioError;
8701
- let audioFailures;
8702
- if (audios.length > 0) {
8703
- let audioResult;
8704
- try {
8705
- audioResult = await processCompositionAudio(
8706
- audios,
8707
- projectDir,
8708
- join17(workDir, "audio-work"),
8709
- audioOutputPath,
8710
- duration,
8711
- abortSignal,
8712
- {
8713
- ffmpegProcessTimeout,
8714
- audioGain,
8715
- masterFxChain,
8716
- urlByAbsentLocalPath,
8717
- keepEncoderInputAt
8718
- },
8719
- compiledDir
8720
- );
8721
- } catch (err) {
8722
- assertNotAborted();
8723
- const detail = err instanceof Error ? err.message : String(err);
8724
- return {
8725
- audioOutputPath,
8726
- hasAudio: false,
8727
- audioProcessMs: Date.now() - stage3Start,
8728
- audioError: detail,
8729
- // Synthesised rather than left undefined. The warning policy reads
8730
- // owner, retryability, reason and stage off this list, so reporting the
8731
- // FATAL failure — the one that took the whole mix down — with an empty
8732
- // one gave it strictly less classification than a single dropped track
8733
- // gets, which is the opposite of what this stage exists to do.
8734
- // "internal" is the honest bucket: the stages enumerate ffmpeg steps
8735
- // and this is the FX render, which is none of them.
8736
- audioFailures: [
8737
- {
8738
- stage: "internal",
8739
- reason: "internal",
8740
- owner: "system",
8741
- retryable: false,
8742
- detail: detail.slice(0, 2e3)
8743
- }
8744
- ]
8745
- };
8746
- }
8747
- assertNotAborted();
8748
- const interrupted = audioResult.failures?.find(
8749
- (failure) => failure.reason === "external_interruption"
8750
- );
8751
- if (interrupted) {
8752
- throw encoderFailureError("Audio processing failed", {
8753
- error: interrupted.detail,
8754
- failureReason: "external_interruption",
8755
- audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
8756
- });
8757
- }
8758
- hasAudio = audioResult.success;
8759
- audioFailures = audioResult.failures;
8760
- if (!hasAudio) {
8761
- audioError = audioResult.error ?? "audio mix failed for an unknown reason";
8762
- for (const failure of audioResult.failures ?? []) {
8763
- log?.warn("Audio track failed", {
8764
- elementId: failure.elementId,
8765
- stage: failure.stage,
8766
- reason: failure.reason,
8767
- owner: failure.owner,
8768
- retryable: failure.retryable,
8769
- detail: failure.detail
8770
- });
8771
- }
8723
+ async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
8724
+ let firstBlock;
8725
+ let laterBlocks = Number.NEGATIVE_INFINITY;
8726
+ let partialLine = "";
8727
+ const readLines = (chunk) => {
8728
+ const lines = (partialLine + chunk).split(/\r|\n/);
8729
+ partialLine = lines.pop() ?? "";
8730
+ for (const line of lines) {
8731
+ const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
8732
+ if (!ftpk) continue;
8733
+ const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
8734
+ if (firstBlock === void 0) firstBlock = block;
8735
+ else laterBlocks = Math.max(laterBlocks, block);
8772
8736
  }
8737
+ };
8738
+ const result = await runFfmpeg3(
8739
+ ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
8740
+ { signal, timeout: timeoutMs, onStderr: readLines }
8741
+ );
8742
+ if (!result.success) {
8743
+ throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
8773
8744
  }
8774
- const audioProcessMs = Date.now() - stage3Start;
8745
+ const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
8775
8746
  return {
8776
- audioOutputPath,
8777
- hasAudio,
8778
- audioProcessMs,
8779
- audioError,
8780
- audioFailures
8747
+ peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
8748
+ firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
8749
+ laterBlocks: checkedDbfs(laterBlocks)
8781
8750
  };
8782
8751
  }
8783
-
8784
- // src/services/render/stages/captureStage.ts
8785
- import { existsSync as existsSync12, readdirSync as readdirSync3, statfsSync as statfsSync2 } from "node:fs";
8786
- import { join as join18 } from "node:path";
8787
- import {
8788
- classifyCaptureFailure as classifyCaptureFailure2,
8789
- frameFileExtension,
8790
- captureFrame,
8791
- captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
8792
- verifyDiskDrawElementSamples,
8793
- writeCapturedFrame,
8794
- closeCaptureSession as closeCaptureSession5,
8795
- completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
8796
- createCaptureSession as createCaptureSession5,
8797
- getCapturePerfSummary as getCapturePerfSummary3,
8798
- initializeSession as initializeSession5,
8799
- prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
8800
- } from "@hyperframes/engine";
8801
- function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
8802
- return workerCount > 1;
8752
+ async function measureTruePeakDbfs(audioPath, options) {
8753
+ const { timeoutMs, signal } = options;
8754
+ const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
8755
+ const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
8756
+ const block = 4800;
8757
+ const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
8758
+ if (count === 1) return wholeFile();
8759
+ const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
8760
+ const peers = new AbortController();
8761
+ const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
8762
+ const scans = await Promise.all(
8763
+ Array.from({ length: count }, (_, k) => {
8764
+ const start = k === 0 ? 0 : cut(k) - block;
8765
+ const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
8766
+ return scanTruePeak(
8767
+ audioPath,
8768
+ `atrim=start_sample=${start}${end},`,
8769
+ timeoutMs,
8770
+ segmentSignal
8771
+ );
8772
+ })
8773
+ ).catch((error) => {
8774
+ peers.abort();
8775
+ throw error;
8776
+ });
8777
+ const last = scans[count - 1];
8778
+ const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
8779
+ if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
8780
+ return wholeFile();
8803
8781
  }
8804
- var JPEG_RAW_RGBA_DIVISOR = 8;
8805
- var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
8806
- var DISK_PROJECTION_SAMPLE_FRAMES = 10;
8807
- function estimateDiskCaptureBytes(totalFrames, captureOptions) {
8808
- const scale = captureOptions.deviceScaleFactor ?? 1;
8809
- const outputWidth = Math.ceil(captureOptions.width * scale);
8810
- const outputHeight = Math.ceil(captureOptions.height * scale);
8811
- const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
8812
- return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
8813
- rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
8814
- ) : rawBytes;
8782
+
8783
+ // src/services/render/audioPadTrim.ts
8784
+ var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
8785
+ var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
8786
+ var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
8787
+ var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
8788
+ function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
8789
+ return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
8815
8790
  }
8816
- function freeDiskBytesAt(path) {
8817
- try {
8818
- const stat = statfsSync2(path);
8819
- return stat.bavail * stat.bsize;
8820
- } catch {
8821
- return null;
8791
+ function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
8792
+ const delta = targetDurationSeconds - sourceDurationSeconds;
8793
+ const targetSec = formatSeconds(targetDurationSeconds);
8794
+ if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
8795
+ return {
8796
+ operation: "copy",
8797
+ steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
8798
+ cleanupPaths: []
8799
+ };
8822
8800
  }
8823
- }
8824
- function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
8825
- const freeBytes = freeDiskBytes(framesDir);
8826
- const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
8827
- if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
8828
- return { available: true, estimatedBytes, freeBytes };
8801
+ if (delta > 0) {
8802
+ return {
8803
+ operation: "pad",
8804
+ steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
8805
+ cleanupPaths: []
8806
+ };
8829
8807
  }
8830
- return { available: false, estimatedBytes, freeBytes };
8808
+ return {
8809
+ operation: "trim",
8810
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
8811
+ cleanupPaths: []
8812
+ };
8831
8813
  }
8832
- function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
8833
- const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
8834
- if (workerCount > 1 && oneWorker.enabled) {
8835
- 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.`;
8836
- }
8837
- switch (oneWorker.reason) {
8838
- case "format_excluded":
8839
- return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
8840
- case "disabled_by_config":
8841
- return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
8842
- case "duration_cap":
8843
- 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.";
8844
- default:
8845
- return "Free up disk space.";
8846
- }
8814
+ function formatSeconds(sec) {
8815
+ return sec.toFixed(6);
8847
8816
  }
8848
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
8849
- const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
8850
- return new Error(
8851
- `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}`
8852
- );
8817
+ function sanitizeProbeFailure(reason, paths) {
8818
+ const message = reason instanceof Error ? reason.message : String(reason);
8819
+ const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
8820
+ return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
8853
8821
  }
8854
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
8855
- const headroom = inspectDiskCaptureHeadroom(
8856
- framesDir,
8857
- totalFrames,
8858
- captureOptions,
8859
- freeDiskBytes
8860
- );
8861
- if (headroom.available) return;
8862
- throw diskCaptureShortfallError(
8863
- headroom.estimatedBytes,
8864
- headroom.freeBytes,
8865
- framesDir,
8866
- "estimate",
8867
- remedy
8822
+ async function padOrTrimAudioToVideoFrameCount(input) {
8823
+ const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
8824
+ const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
8825
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
8826
+ const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
8827
+ const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
8828
+ const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
8829
+ const [videoResult, audioResult] = await Promise.allSettled([
8830
+ probeVideo(input.videoPath),
8831
+ probeAudio(input.audioPath, input.signal)
8832
+ ]);
8833
+ const probePaths = [input.videoPath, input.audioPath, input.outputPath];
8834
+ if (videoResult.status === "rejected") {
8835
+ return failResult(
8836
+ input.outputPath,
8837
+ 0,
8838
+ audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
8839
+ `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
8840
+ videoResult.reason,
8841
+ probePaths
8842
+ )}`
8843
+ );
8844
+ }
8845
+ if (audioResult.status === "rejected") {
8846
+ return failResult(
8847
+ input.outputPath,
8848
+ 0,
8849
+ 0,
8850
+ `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
8851
+ audioResult.reason,
8852
+ probePaths
8853
+ )}`
8854
+ );
8855
+ }
8856
+ const videoInfo = videoResult.value;
8857
+ const audioInfo = audioResult.value;
8858
+ if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
8859
+ return failResult(
8860
+ input.outputPath,
8861
+ 0,
8862
+ audioInfo.durationSeconds,
8863
+ `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
8864
+ );
8865
+ }
8866
+ const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
8867
+ const plan = buildPadTrimAudioPlan(
8868
+ input.audioPath,
8869
+ input.outputPath,
8870
+ audioInfo.durationSeconds,
8871
+ targetDurationSeconds
8868
8872
  );
8869
- }
8870
- function measureCaptureFrameBytes(workDir, framesDir) {
8871
- let total = sampleDirectoryBytes(framesDir);
8872
- if (!existsSync12(workDir)) return total;
8873
- for (const attempt of readdirSync3(workDir)) {
8874
- if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
8875
- total += sampleDirectoryBytes(join18(workDir, attempt));
8876
- continue;
8877
- }
8878
- if (!/^capture-attempt-\d+$/.test(attempt)) continue;
8879
- const attemptDir = join18(workDir, attempt);
8880
- for (const name of readdirSync3(attemptDir)) {
8881
- if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join18(attemptDir, name));
8873
+ try {
8874
+ for (const step of plan.steps) {
8875
+ const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
8876
+ if (!ffmpegResult.success) {
8877
+ return {
8878
+ success: false,
8879
+ outputPath: input.outputPath,
8880
+ targetDurationSeconds,
8881
+ sourceDurationSeconds: audioInfo.durationSeconds,
8882
+ operation: plan.operation,
8883
+ error: ffmpegResult.error,
8884
+ failureReason: ffmpegResult.failureReason
8885
+ };
8886
+ }
8882
8887
  }
8888
+ } catch (err) {
8889
+ return {
8890
+ success: false,
8891
+ outputPath: input.outputPath,
8892
+ targetDurationSeconds,
8893
+ sourceDurationSeconds: audioInfo.durationSeconds,
8894
+ operation: plan.operation,
8895
+ error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
8896
+ };
8897
+ } finally {
8898
+ for (const path of plan.cleanupPaths) rmSync7(path, { force: true });
8883
8899
  }
8884
- return total;
8885
- }
8886
- function createDiskCaptureProjection(input) {
8887
- const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
8888
- let checked = false;
8889
- return (capturedFrames) => {
8890
- if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
8891
- const measured = measureBytes();
8892
- if (measured <= 0) return;
8893
- checked = true;
8894
- const remaining = totalFrames - capturedFrames;
8895
- const freeBytes = freeDiskBytes(framesDir);
8896
- if (remaining <= 0 || freeBytes === null) return;
8897
- const needBytes = Math.ceil(measured / capturedFrames * remaining);
8898
- if (needBytes > freeBytes * 0.9) {
8899
- throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
8900
+ let loweredDb = 0;
8901
+ if (probeTruePeak) {
8902
+ const correction = await enforceAacTruePeak({
8903
+ audioPath: input.outputPath,
8904
+ targetDurationSeconds,
8905
+ signal: input.signal,
8906
+ probeTruePeak,
8907
+ encode
8908
+ });
8909
+ if (!correction.success) {
8910
+ return {
8911
+ success: false,
8912
+ outputPath: input.outputPath,
8913
+ targetDurationSeconds,
8914
+ sourceDurationSeconds: audioInfo.durationSeconds,
8915
+ operation: plan.operation,
8916
+ error: correction.error,
8917
+ failureReason: correction.failureReason
8918
+ };
8900
8919
  }
8920
+ loweredDb = correction.loweredDb ?? 0;
8921
+ }
8922
+ return {
8923
+ success: true,
8924
+ outputPath: input.outputPath,
8925
+ targetDurationSeconds,
8926
+ sourceDurationSeconds: audioInfo.durationSeconds,
8927
+ operation: plan.operation,
8928
+ ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
8901
8929
  };
8902
8930
  }
8903
- async function runCaptureStage(input) {
8904
- const {
8905
- fileServer,
8906
- workDir,
8907
- framesDir,
8908
- job,
8909
- totalFrames,
8910
- cfg,
8911
- plan,
8912
- log,
8913
- captureAttempts,
8914
- buildCaptureOptions,
8915
- createRenderVideoFrameInjector,
8916
- abortSignal,
8917
- onProgress,
8918
- frameRange,
8919
- dedupPerfs
8920
- } = input;
8921
- let { probeSession } = input;
8922
- let { workerCount } = plan;
8923
- const { forceScreenshot } = plan;
8924
- let lastBrowserConsole = [];
8925
- let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
8926
- const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
8927
- if (frameRange !== void 0) {
8928
- if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
8929
- throw new Error(
8930
- `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
8931
+ async function constrainAacTruePeak(input) {
8932
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
8933
+ return enforceAacTruePeak({
8934
+ audioPath: input.audioPath,
8935
+ targetDurationSeconds: input.targetDurationSeconds,
8936
+ signal: input.signal,
8937
+ probeTruePeak: (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }),
8938
+ encode: defaultAacEncoder(timeoutMs, input.signal, input.sourcePath)
8939
+ });
8940
+ }
8941
+ async function enforceAacTruePeak(input) {
8942
+ let scratchDir;
8943
+ try {
8944
+ scratchDir = mkdtempSync3(join17(dirname7(input.audioPath), ".true-peak-"));
8945
+ const correctedPath = join17(scratchDir, "audio.m4a");
8946
+ const measurements = [];
8947
+ let attenuationDb = 0;
8948
+ let measuredPath = input.audioPath;
8949
+ for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
8950
+ const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
8951
+ measurements.push(
8952
+ `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
8931
8953
  );
8932
- }
8933
- const rangeFrames = frameRange.endFrame - frameRange.startFrame;
8934
- if (rangeFrames !== totalFrames) {
8935
- throw new Error(
8936
- `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
8954
+ if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
8955
+ return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
8956
+ }
8957
+ if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
8958
+ if (measuredPath !== correctedPath) return { success: true };
8959
+ renameSync4(correctedPath, input.audioPath);
8960
+ return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
8961
+ }
8962
+ if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
8963
+ const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
8964
+ attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
8965
+ const result = await input.encode(
8966
+ input.audioPath,
8967
+ correctedPath,
8968
+ input.targetDurationSeconds,
8969
+ aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
8937
8970
  );
8971
+ if (!result.success) {
8972
+ return {
8973
+ success: false,
8974
+ error: sanitizeProbeFailure(
8975
+ result.error ?? "audioPadTrim: failed to constrain AAC true peak",
8976
+ [input.audioPath, correctedPath]
8977
+ ),
8978
+ failureReason: result.failureReason
8979
+ };
8980
+ }
8981
+ measuredPath = correctedPath;
8938
8982
  }
8983
+ return {
8984
+ success: false,
8985
+ error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
8986
+ };
8987
+ } catch (err) {
8988
+ return {
8989
+ success: false,
8990
+ error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
8991
+ err,
8992
+ scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
8993
+ )}`
8994
+ };
8995
+ } finally {
8996
+ if (scratchDir) rmSync7(scratchDir, { recursive: true, force: true });
8939
8997
  }
8940
- const captureOptions = buildCaptureOptions();
8941
- const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
8942
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
8943
- const checkDiskProjection = createDiskCaptureProjection({
8944
- framesDir,
8945
- totalFrames,
8946
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
8947
- remedy
8948
- });
8949
- if (workerCount > 1) {
8950
- const attempts = await executeDiskCaptureWithAdaptiveRetry({
8951
- serverUrl: fileServer.url,
8952
- workDir,
8953
- framesDir,
8954
- totalFrames,
8955
- initialWorkerCount: workerCount,
8956
- allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
8957
- frameExt: frameFileExtension(captureOptions.format),
8958
- captureOptions,
8959
- createBeforeCaptureHook: createRenderVideoFrameInjector,
8960
- abortSignal,
8961
- frameRangeStart: frameRange?.startFrame,
8962
- dedupPerfs,
8963
- onProgress: (progress) => {
8964
- if (progress.latestWorkerPhase) {
8965
- if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
8966
- return;
8967
- }
8968
- if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
8969
- job.framesRendered = progress.capturedFrames;
8970
- checkDiskProjection(progress.capturedFrames);
8971
- const frameProgress = progress.capturedFrames / progress.totalFrames;
8972
- reportFrameProgress(
8973
- job,
8974
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
8975
- Math.round(25 + frameProgress * 45),
8976
- onProgress,
8977
- progress.capturedFrames,
8978
- progress.totalFrames
8979
- );
8980
- },
8981
- cfg: captureCfg,
8982
- log
8983
- });
8984
- captureAttempts.push(...attempts);
8985
- const lastAttempt = attempts[attempts.length - 1];
8986
- if (lastAttempt) {
8987
- workerCount = lastAttempt.workers;
8998
+ }
8999
+ function failResult(outputPath, target, source, error) {
9000
+ return {
9001
+ success: false,
9002
+ outputPath,
9003
+ targetDurationSeconds: target,
9004
+ sourceDurationSeconds: source,
9005
+ operation: "copy",
9006
+ error
9007
+ };
9008
+ }
9009
+ async function defaultProbeVideoFrameInfo(videoPath, signal) {
9010
+ const fastInfo = await runFfprobeJson(
9011
+ [
9012
+ "-v",
9013
+ "error",
9014
+ "-select_streams",
9015
+ "v:0",
9016
+ "-show_entries",
9017
+ "stream=nb_frames,r_frame_rate",
9018
+ "-of",
9019
+ "json",
9020
+ "--",
9021
+ videoPath
9022
+ ],
9023
+ signal
9024
+ );
9025
+ let stream = fastInfo.streams?.[0];
9026
+ const fastCount = Number(stream?.nb_frames);
9027
+ if (stream && Number.isFinite(fastCount) && fastCount > 0) {
9028
+ return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
9029
+ }
9030
+ const slowInfo = await runFfprobeJson(
9031
+ [
9032
+ "-v",
9033
+ "error",
9034
+ "-select_streams",
9035
+ "v:0",
9036
+ "-count_packets",
9037
+ "-show_entries",
9038
+ "stream=nb_read_packets,r_frame_rate",
9039
+ "-of",
9040
+ "json",
9041
+ "--",
9042
+ videoPath
9043
+ ],
9044
+ signal
9045
+ );
9046
+ stream = slowInfo.streams?.[0];
9047
+ if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
9048
+ const slowCount = Number(stream.nb_read_packets);
9049
+ if (!Number.isFinite(slowCount) || slowCount <= 0) {
9050
+ throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
9051
+ }
9052
+ return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
9053
+ }
9054
+ function parseFrameRate(rate) {
9055
+ const [n, d] = rate.split("/");
9056
+ const fpsNum = Number(n);
9057
+ const fpsDen = d === void 0 ? 1 : Number(d);
9058
+ if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
9059
+ throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
9060
+ }
9061
+ return { fpsNum, fpsDen };
9062
+ }
9063
+ async function defaultProbeAudioInfo(audioPath, signal) {
9064
+ const metadata = await extractAudioMetadata3(audioPath, { signal });
9065
+ return {
9066
+ durationSeconds: metadata.durationSeconds,
9067
+ sampleRate: metadata.sampleRate,
9068
+ channels: metadata.channels,
9069
+ audioCodec: metadata.audioCodec
9070
+ };
9071
+ }
9072
+ async function defaultRunFfmpeg(args, timeoutMs, signal) {
9073
+ return toStepResult(await runFfmpeg4(args, { signal, timeout: timeoutMs }), timeoutMs);
9074
+ }
9075
+ function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
9076
+ return async (inputPath, outputPath, durationSeconds, filter) => {
9077
+ const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
9078
+ return toStepResult(
9079
+ await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
9080
+ timeoutMs
9081
+ );
9082
+ };
9083
+ }
9084
+ function toStepResult(result, timeoutMs) {
9085
+ if (result.success) return { success: true };
9086
+ return {
9087
+ success: false,
9088
+ error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
9089
+ failureReason: result.failureReason
9090
+ };
9091
+ }
9092
+ async function runFfprobeJson(args, signal) {
9093
+ if (!args.includes("--")) {
9094
+ throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
9095
+ }
9096
+ const proc = spawn(getFfprobeBinary(), args, {
9097
+ stdio: ["ignore", "pipe", "pipe"],
9098
+ windowsHide: true
9099
+ });
9100
+ trackChildProcess(proc);
9101
+ let stdout = "";
9102
+ proc.stdout.on("data", (data) => {
9103
+ stdout += data.toString();
9104
+ });
9105
+ const managed = new ManagedChildProcess(proc, {
9106
+ signal,
9107
+ deadlineAtMs: Date.now() + 3e4
9108
+ });
9109
+ const outcome = await managed.wait();
9110
+ if (outcome.reason === "spawn_error") {
9111
+ if (outcome.error?.code === "ENOENT") {
9112
+ throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
8988
9113
  }
8989
- if (probeSession) {
8990
- captureBeyondViewport = probeSession.options.captureBeyondViewport;
8991
- lastBrowserConsole = probeSession.browserConsoleBuffer;
8992
- await closeCaptureSession5(probeSession);
8993
- probeSession = null;
9114
+ throw outcome.error ?? new Error(outcome.stderr);
9115
+ }
9116
+ if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
9117
+ const probed = args[args.length - 1];
9118
+ const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
9119
+ throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
9120
+ }
9121
+ try {
9122
+ return JSON.parse(stdout);
9123
+ } catch (err) {
9124
+ throw new Error(`Failed to parse ffprobe output: ${err.message}`);
9125
+ }
9126
+ }
9127
+
9128
+ // src/services/render/stages/audioStage.ts
9129
+ function assertAudioNotInterrupted(failures) {
9130
+ const interrupted = failures?.find((failure) => failure.reason === "external_interruption");
9131
+ if (!interrupted) return;
9132
+ throw encoderFailureError("Audio processing failed", {
9133
+ error: interrupted.detail,
9134
+ failureReason: "external_interruption",
9135
+ audioFailures: audioFailureDiagnostics([
9136
+ interrupted,
9137
+ ...(failures ?? []).filter((failure) => failure !== interrupted)
9138
+ ])
9139
+ });
9140
+ }
9141
+ async function runAudioStage(input) {
9142
+ const stageStart = Date.now();
9143
+ const pcmSourcePath = join18(input.workDir, "audio-source.wav");
9144
+ const duration = input.totalFrames * input.fps.den / input.fps.num;
9145
+ try {
9146
+ const result = await mixAudioForDelivery(input, duration, pcmSourcePath);
9147
+ let audioLoweredDb;
9148
+ if (result.hasAudio) {
9149
+ const peak = await constrainAacTruePeak({
9150
+ audioPath: result.audioOutputPath,
9151
+ sourcePath: pcmSourcePath,
9152
+ targetDurationSeconds: duration,
9153
+ signal: input.abortSignal,
9154
+ timeoutMs: input.ffmpegProcessTimeout
9155
+ });
9156
+ if (!peak.success) throw encoderFailureError("Audio true-peak check failed", peak);
9157
+ audioLoweredDb = peak.loweredDb;
8994
9158
  }
8995
- } else {
8996
- const sequential = await runSequentialDiskCapture(
8997
- input,
8998
- captureOptions,
8999
- captureCfg,
9000
- checkDiskProjection
9001
- );
9002
- probeSession = null;
9003
- lastBrowserConsole = sequential.lastBrowserConsole;
9004
- captureBeyondViewport = sequential.captureBeyondViewport;
9159
+ return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
9160
+ } finally {
9161
+ rmSync8(pcmSourcePath, { force: true });
9005
9162
  }
9006
- return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
9007
9163
  }
9008
- async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
9164
+ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9009
9165
  const {
9010
- fileServer,
9011
- framesDir,
9012
- totalFrames,
9013
- log,
9014
- createRenderVideoFrameInjector,
9166
+ projectDir,
9167
+ workDir,
9168
+ compiledDir,
9169
+ ffmpegProcessTimeout,
9170
+ audioGain,
9171
+ audios,
9172
+ masterFxChain,
9173
+ urlByAbsentLocalPath,
9015
9174
  abortSignal,
9016
9175
  assertNotAborted,
9017
- frameRange
9176
+ log
9018
9177
  } = input;
9019
- let { probeSession } = input;
9020
- let lastBrowserConsole = [];
9021
- let captureBeyondViewport;
9022
- const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
9023
- const rangeFrames = rangeEnd - rangeStart;
9024
- const frameExt = frameFileExtension(captureOptions.format);
9025
- let transientRetriesUsed = 0;
9026
- let resumeFrom = 0;
9027
- while (true) {
9028
- const videoInjector = createRenderVideoFrameInjector();
9029
- const session = probeSession ?? await createCaptureSession5(
9030
- fileServer.url,
9031
- framesDir,
9032
- captureOptions,
9033
- videoInjector,
9034
- captureCfg
9035
- );
9036
- captureBeyondViewport = session.options.captureBeyondViewport;
9178
+ const audioOutputPath = join18(workDir, MIXED_AUDIO_FILENAME);
9179
+ let hasAudio = false;
9180
+ let audioError;
9181
+ let audioFailures;
9182
+ if (audios.length > 0) {
9183
+ let audioResult;
9037
9184
  try {
9038
- if (probeSession) {
9039
- prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
9040
- probeSession = null;
9041
- }
9042
- await ensureSessionReady(session);
9043
- assertNotAborted();
9044
- lastBrowserConsole = session.browserConsoleBuffer;
9045
- await captureSessionFrames(session, {
9046
- input,
9047
- rangeStart,
9048
- rangeEnd,
9049
- resumeFrom,
9050
- checkDiskProjection
9051
- });
9052
- break;
9053
- } catch (error) {
9054
- lastBrowserConsole = session.browserConsoleBuffer;
9055
- const resume = planTransientResume(error, {
9185
+ audioResult = await processCompositionAudio(
9186
+ audios,
9187
+ projectDir,
9188
+ join18(workDir, "audio-work"),
9189
+ audioOutputPath,
9190
+ duration,
9056
9191
  abortSignal,
9057
- rangeFrames,
9058
- framesDir,
9059
- frameExt,
9060
- retriesUsed: transientRetriesUsed
9061
- });
9062
- if (resume !== null) {
9063
- transientRetriesUsed++;
9064
- resumeFrom = resume;
9065
- log.warn(
9066
- "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
9067
- {
9068
- resumeFrom,
9069
- rangeFrames,
9070
- transientRetriesUsed,
9071
- error: error instanceof Error ? error.message : String(error)
9072
- }
9073
- );
9074
- continue;
9075
- }
9076
- throw wrapCaptureStageError(error, lastBrowserConsole);
9077
- } finally {
9078
- lastBrowserConsole = session.browserConsoleBuffer;
9079
- await closeCaptureSession5(session);
9192
+ {
9193
+ ffmpegProcessTimeout,
9194
+ audioGain,
9195
+ masterFxChain,
9196
+ urlByAbsentLocalPath,
9197
+ keepEncoderInputAt: pcmSourcePath
9198
+ },
9199
+ compiledDir
9200
+ );
9201
+ } catch (err) {
9202
+ assertNotAborted();
9203
+ const detail = err instanceof Error ? err.message : String(err);
9204
+ return {
9205
+ audioOutputPath,
9206
+ hasAudio: false,
9207
+ audioError: detail,
9208
+ // Synthesised rather than left undefined. The warning policy reads
9209
+ // owner, retryability, reason and stage off this list, so reporting the
9210
+ // FATAL failure — the one that took the whole mix down — with an empty
9211
+ // one gave it strictly less classification than a single dropped track
9212
+ // gets, which is the opposite of what this stage exists to do.
9213
+ // "internal" is the honest bucket: the stages enumerate ffmpeg steps
9214
+ // and this is the FX render, which is none of them.
9215
+ audioFailures: [
9216
+ {
9217
+ stage: "internal",
9218
+ reason: "internal",
9219
+ owner: "system",
9220
+ retryable: false,
9221
+ detail: detail.slice(0, 2e3)
9222
+ }
9223
+ ]
9224
+ };
9225
+ }
9226
+ assertNotAborted();
9227
+ assertAudioNotInterrupted(audioResult.failures);
9228
+ hasAudio = audioResult.success;
9229
+ audioFailures = audioResult.failures;
9230
+ if (!hasAudio) {
9231
+ audioError = audioResult.error ?? "audio mix failed for an unknown reason";
9232
+ for (const failure of audioResult.failures ?? []) {
9233
+ log?.warn("Audio track failed", {
9234
+ elementId: failure.elementId,
9235
+ stage: failure.stage,
9236
+ reason: failure.reason,
9237
+ owner: failure.owner,
9238
+ retryable: failure.retryable,
9239
+ detail: failure.detail
9240
+ });
9241
+ }
9080
9242
  }
9081
9243
  }
9082
- return { lastBrowserConsole, captureBeyondViewport };
9244
+ return {
9245
+ audioOutputPath,
9246
+ hasAudio,
9247
+ audioError,
9248
+ audioFailures
9249
+ };
9083
9250
  }
9084
- function resolveCaptureRange(frameRange, totalFrames) {
9085
- return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
9251
+
9252
+ // src/services/render/stages/captureStage.ts
9253
+ import { existsSync as existsSync12, readdirSync as readdirSync3, statfsSync as statfsSync2 } from "node:fs";
9254
+ import { join as join19 } from "node:path";
9255
+ import {
9256
+ classifyCaptureFailure as classifyCaptureFailure2,
9257
+ frameFileExtension,
9258
+ captureFrame,
9259
+ captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
9260
+ verifyDiskDrawElementSamples,
9261
+ writeCapturedFrame,
9262
+ closeCaptureSession as closeCaptureSession5,
9263
+ completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
9264
+ createCaptureSession as createCaptureSession5,
9265
+ getCapturePerfSummary as getCapturePerfSummary3,
9266
+ initializeSession as initializeSession5,
9267
+ prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
9268
+ } from "@hyperframes/engine";
9269
+ function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
9270
+ return workerCount > 1;
9086
9271
  }
9087
- function planTransientResume(error, ctx) {
9088
- const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
9089
- const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
9090
- const [firstMissing] = missing;
9091
- if (!firstMissing) return null;
9092
- return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
9272
+ var JPEG_RAW_RGBA_DIVISOR = 8;
9273
+ var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
9274
+ var DISK_PROJECTION_SAMPLE_FRAMES = 10;
9275
+ function estimateDiskCaptureBytes(totalFrames, captureOptions) {
9276
+ const scale = captureOptions.deviceScaleFactor ?? 1;
9277
+ const outputWidth = Math.ceil(captureOptions.width * scale);
9278
+ const outputHeight = Math.ceil(captureOptions.height * scale);
9279
+ const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
9280
+ return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
9281
+ rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
9282
+ ) : rawBytes;
9093
9283
  }
9094
- async function ensureSessionReady(session) {
9095
- if (!session.isInitialized) {
9096
- await initializeSession5(session);
9097
- return;
9098
- }
9099
- if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
9100
- await completeDeferredDrawElementInit3(session);
9284
+ function freeDiskBytesAt(path) {
9285
+ try {
9286
+ const stat = statfsSync2(path);
9287
+ return stat.bavail * stat.bsize;
9288
+ } catch {
9289
+ return null;
9101
9290
  }
9102
9291
  }
9103
- async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
9104
- const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
9105
- const rangeFrames = rangeEnd - rangeStart;
9106
- const reportFrame = (fileIndex) => {
9107
- job.framesRendered = fileIndex + 1;
9108
- checkDiskProjection(fileIndex + 1);
9109
- reportFrameProgress(
9110
- job,
9111
- `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
9112
- Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
9113
- onProgress,
9114
- fileIndex + 1,
9115
- rangeFrames
9116
- );
9117
- };
9118
- if (session.workerEncodeEnabled) {
9119
- let prev = null;
9120
- const drainPrev = async () => {
9121
- if (!prev) return;
9122
- assertNotAborted();
9123
- const buf = await prev.encodeResult;
9124
- writeCapturedFrame(session, prev.fileIndex, buf);
9125
- reportFrame(prev.fileIndex);
9126
- };
9127
- for (let i = resumeFrom; i < rangeFrames; i++) {
9128
- assertNotAborted();
9129
- const absoluteIdx = rangeStart + i;
9130
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9131
- const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
9132
- await drainPrev();
9133
- prev = { fileIndex: i, encodeResult };
9134
- }
9135
- await drainPrev();
9136
- } else {
9137
- for (let i = resumeFrom; i < rangeFrames; i++) {
9138
- assertNotAborted();
9139
- const absoluteIdx = rangeStart + i;
9140
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9141
- await captureFrame(session, i, time);
9142
- reportFrame(i);
9143
- }
9292
+ function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
9293
+ const freeBytes = freeDiskBytes(framesDir);
9294
+ const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
9295
+ if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
9296
+ return { available: true, estimatedBytes, freeBytes };
9144
9297
  }
9145
- await verifyDiskDrawElementSamples(
9146
- session,
9147
- {
9148
- workerId: 0,
9149
- startFrame: rangeStart + resumeFrom,
9150
- endFrame: rangeEnd,
9151
- outputDir: framesDir,
9152
- outputFrameOffset: rangeStart
9153
- },
9154
- false
9155
- );
9156
- dedupPerfs.push(getCapturePerfSummary3(session));
9298
+ return { available: false, estimatedBytes, freeBytes };
9157
9299
  }
9158
-
9159
- // src/services/render/stages/captureHdrStage.ts
9160
- import { existsSync as existsSync14, mkdirSync as mkdirSync7 } from "node:fs";
9161
- import { join as join22 } from "node:path";
9162
- import {
9163
- closeCaptureSession as closeCaptureSession7,
9164
- cloneCaptureWarnings as cloneCaptureWarnings2,
9165
- createCaptureSession as createCaptureSession7,
9166
- initTransparentBackground as initTransparentBackground2,
9167
- initializeSession as initializeSession7,
9168
- spawnStreamingEncoder as spawnStreamingEncoder3
9169
- } from "@hyperframes/engine";
9170
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
9171
-
9172
- // src/services/hdrImageTransferCache.ts
9173
- import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
9174
- var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
9175
- function createHdrImageTransferCache(options = {}) {
9176
- const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
9177
- if (!Number.isInteger(maxBytes) || maxBytes < 0) {
9178
- throw new Error(
9179
- `createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
9180
- );
9300
+ function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
9301
+ const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
9302
+ if (workerCount > 1 && oneWorker.enabled) {
9303
+ 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.`;
9181
9304
  }
9182
- const entries = /* @__PURE__ */ new Map();
9183
- let totalBytes = 0;
9184
- function makeKey(imageId, targetTransfer) {
9185
- return `${imageId}|${targetTransfer}`;
9305
+ switch (oneWorker.reason) {
9306
+ case "format_excluded":
9307
+ return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
9308
+ case "disabled_by_config":
9309
+ return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
9310
+ case "duration_cap":
9311
+ 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.";
9312
+ default:
9313
+ return "Free up disk space.";
9186
9314
  }
9187
- function evictUntilRoom(needed) {
9188
- while (totalBytes + needed > maxBytes && entries.size > 0) {
9189
- const lruKey = entries.keys().next().value;
9190
- if (lruKey === void 0) break;
9191
- const evicted = entries.get(lruKey);
9192
- if (evicted) totalBytes -= evicted.byteLength;
9193
- entries.delete(lruKey);
9315
+ }
9316
+ function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
9317
+ const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
9318
+ return new Error(
9319
+ `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}`
9320
+ );
9321
+ }
9322
+ function measureCaptureFrameBytes(workDir, framesDir) {
9323
+ let total = sampleDirectoryBytes(framesDir);
9324
+ if (!existsSync12(workDir)) return total;
9325
+ for (const attempt of readdirSync3(workDir)) {
9326
+ if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
9327
+ total += sampleDirectoryBytes(join19(workDir, attempt));
9328
+ continue;
9329
+ }
9330
+ if (!/^capture-attempt-\d+$/.test(attempt)) continue;
9331
+ const attemptDir = join19(workDir, attempt);
9332
+ for (const name of readdirSync3(attemptDir)) {
9333
+ if (/^worker-\d+$/.test(name)) total += sampleDirectoryBytes(join19(attemptDir, name));
9194
9334
  }
9195
9335
  }
9196
- return {
9197
- getConverted(imageId, sourceTransfer, targetTransfer, source) {
9198
- if (sourceTransfer === targetTransfer) {
9199
- return source;
9200
- }
9201
- if (maxBytes === 0) {
9202
- const fresh = Buffer.from(source);
9203
- convertTransfer2(fresh, sourceTransfer, targetTransfer);
9204
- return fresh;
9205
- }
9206
- const key = makeKey(imageId, targetTransfer);
9207
- const existing = entries.get(key);
9208
- if (existing) {
9209
- entries.delete(key);
9210
- entries.set(key, existing);
9211
- return existing;
9212
- }
9213
- const converted = Buffer.from(source);
9214
- convertTransfer2(converted, sourceTransfer, targetTransfer);
9215
- if (converted.byteLength > maxBytes) {
9216
- return converted;
9217
- }
9218
- evictUntilRoom(converted.byteLength);
9219
- entries.set(key, converted);
9220
- totalBytes += converted.byteLength;
9221
- return converted;
9222
- },
9223
- size() {
9224
- return entries.size;
9225
- },
9226
- bytesUsed() {
9227
- return totalBytes;
9336
+ return total;
9337
+ }
9338
+ function createDiskCaptureProjection(input) {
9339
+ const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
9340
+ let checked = false;
9341
+ return (capturedFrames) => {
9342
+ if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
9343
+ const measured = measureBytes();
9344
+ if (measured <= 0) return;
9345
+ checked = true;
9346
+ const remaining = totalFrames - capturedFrames;
9347
+ const freeBytes = freeDiskBytes(framesDir);
9348
+ if (remaining <= 0 || freeBytes === null) return;
9349
+ const needBytes = Math.ceil(measured / capturedFrames * remaining);
9350
+ if (needBytes > freeBytes * 0.9) {
9351
+ throw diskCaptureShortfallError(needBytes, freeBytes, framesDir, "measured", remedy);
9228
9352
  }
9229
9353
  };
9230
9354
  }
9231
-
9232
- // src/services/render/stages/captureHdrSequentialLoop.ts
9233
- import { join as join19 } from "node:path";
9234
- import {
9235
- TRANSITIONS,
9236
- crossfade
9237
- } from "@hyperframes/engine";
9238
- async function runSequentialLayeredFrameLoop(input) {
9355
+ async function runCaptureStage(input) {
9239
9356
  const {
9357
+ fileServer,
9358
+ workDir,
9359
+ framesDir,
9240
9360
  job,
9241
- log,
9242
- width,
9243
- height,
9244
9361
  totalFrames,
9245
- nativeHdrIds,
9246
- nativeHdrImageIds,
9247
- hdrCompositeCtx,
9248
- hdrPerf,
9249
- hdrEncoder,
9250
- domSession,
9251
- transitionRanges,
9252
- sceneElements,
9253
- compositeTransfer,
9254
- hdrTargetTransfer,
9255
- hdrVideoEndTimes,
9256
- cleanedUpVideos,
9257
- hdrVideoFrameSources,
9258
- debugDumpEnabled,
9259
- debugDumpDir,
9260
- assertNotAborted,
9261
- onProgress
9362
+ cfg,
9363
+ plan,
9364
+ log,
9365
+ captureAttempts,
9366
+ buildCaptureOptions,
9367
+ createRenderVideoFrameInjector,
9368
+ abortSignal,
9369
+ onProgress,
9370
+ frameRange,
9371
+ dedupPerfs
9262
9372
  } = input;
9263
- const beforeCaptureHook = domSession.onBeforeCapture;
9264
- const bufSize = width * height * 6;
9265
- const hasTransitions = transitionRanges.length > 0;
9266
- const transitionBuffers = hasTransitions ? {
9267
- bufferA: Buffer.alloc(bufSize),
9268
- bufferB: Buffer.alloc(bufSize),
9269
- output: Buffer.alloc(bufSize)
9270
- } : null;
9271
- const normalCanvas = Buffer.alloc(bufSize);
9272
- for (let i = 0; i < totalFrames; i++) {
9273
- assertNotAborted();
9274
- const time = i * job.config.fps.den / job.config.fps.num;
9275
- if (hdrPerf) hdrPerf.frames += 1;
9276
- const stackingInfo = await seekInjectAndQueryStacking(
9277
- domSession.page,
9278
- time,
9279
- beforeCaptureHook,
9280
- nativeHdrIds,
9281
- hdrPerf,
9282
- "frameSeekMs",
9283
- "frameInjectMs",
9284
- "stackingQueryMs"
9285
- );
9286
- const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
9287
- if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
9288
- const hdrEl = stackingInfo.find((e) => e.isHdr);
9289
- log.debug("[Render] HDR layer composite frame", {
9290
- frame: i,
9291
- time: time.toFixed(2),
9292
- hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
9293
- stackingCount: stackingInfo.length,
9294
- activeTransition: activeTransition?.shader
9295
- });
9296
- }
9297
- if (activeTransition && transitionBuffers) {
9298
- if (hdrPerf) hdrPerf.transitionFrames += 1;
9299
- const transitionTimingStart = Date.now();
9300
- const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
9301
- const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
9302
- const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
9303
- timeHdrPhase(hdrPerf, "canvasClearMs", () => {
9304
- transitionBuffers.bufferA.fill(0);
9305
- transitionBuffers.bufferB.fill(0);
9306
- });
9307
- const sceneCaptures = [
9308
- [transitionBuffers.bufferA, sceneAIds],
9309
- [transitionBuffers.bufferB, sceneBIds]
9310
- ];
9311
- for (const [sceneBuf, sceneIds] of sceneCaptures) {
9312
- assertNotAborted();
9313
- await captureSceneIntoBuffer({
9314
- session: domSession,
9315
- sceneBuf,
9316
- sceneIds,
9317
- stackingInfo,
9318
- time,
9319
- width,
9320
- height,
9321
- nativeHdrIds,
9322
- nativeHdrImageIds,
9323
- beforeCaptureHook,
9324
- hdrCompositeCtx,
9325
- compositeTransfer,
9326
- hdrTargetTransfer,
9327
- hdrPerf,
9328
- log,
9329
- frameIdx: i
9330
- });
9331
- }
9332
- const shaderName = activeTransition.shader;
9333
- const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
9334
- transitionFn(
9335
- transitionBuffers.bufferA,
9336
- transitionBuffers.bufferB,
9337
- transitionBuffers.output,
9338
- width,
9339
- height,
9340
- progress
9341
- );
9342
- addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
9343
- await timeHdrPhaseAsync(
9344
- hdrPerf,
9345
- "encoderWriteMs",
9346
- async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
9347
- );
9348
- } else {
9349
- if (hdrPerf) hdrPerf.normalFrames += 1;
9350
- timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
9351
- await timeHdrPhaseAsync(
9352
- hdrPerf,
9353
- "normalCompositeMs",
9354
- () => compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, void 0, i)
9373
+ let { probeSession } = input;
9374
+ let { workerCount } = plan;
9375
+ const { forceScreenshot } = plan;
9376
+ let lastBrowserConsole = [];
9377
+ let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
9378
+ const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
9379
+ if (frameRange !== void 0) {
9380
+ if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
9381
+ throw new Error(
9382
+ `[captureStage] invalid frameRange: ${JSON.stringify(frameRange)}. Expected non-negative startFrame strictly less than endFrame.`
9355
9383
  );
9356
- if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9357
- writeFileExclusiveSync(
9358
- join19(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9359
- normalCanvas
9360
- );
9361
- }
9362
- await timeHdrPhaseAsync(
9363
- hdrPerf,
9364
- "encoderWriteMs",
9365
- async () => ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i, hdrEncoder)
9384
+ }
9385
+ const rangeFrames = frameRange.endFrame - frameRange.startFrame;
9386
+ if (rangeFrames !== totalFrames) {
9387
+ throw new Error(
9388
+ `[captureStage] frameRange size (${rangeFrames}) must equal totalFrames (${totalFrames}). Received frameRange=${JSON.stringify(frameRange)}.`
9366
9389
  );
9367
9390
  }
9368
- cleanupEndedHdrVideos({
9369
- time,
9370
- activeTransition,
9371
- hdrVideoEndTimes,
9372
- cleanedUpVideos,
9373
- hdrVideoFrameSources,
9374
- sceneElements,
9391
+ }
9392
+ const captureOptions = buildCaptureOptions();
9393
+ const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
9394
+ const checkDiskProjection = createDiskCaptureProjection({
9395
+ framesDir,
9396
+ totalFrames,
9397
+ measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
9398
+ remedy
9399
+ });
9400
+ if (workerCount > 1) {
9401
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
9402
+ serverUrl: fileServer.url,
9403
+ workDir,
9404
+ framesDir,
9405
+ totalFrames,
9406
+ initialWorkerCount: workerCount,
9407
+ allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
9408
+ frameExt: frameFileExtension(captureOptions.format),
9409
+ captureOptions,
9410
+ createBeforeCaptureHook: createRenderVideoFrameInjector,
9411
+ abortSignal,
9412
+ frameRangeStart: frameRange?.startFrame,
9413
+ dedupPerfs,
9414
+ onProgress: (progress) => {
9415
+ if (progress.latestWorkerPhase) {
9416
+ if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
9417
+ return;
9418
+ }
9419
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
9420
+ job.framesRendered = progress.capturedFrames;
9421
+ checkDiskProjection(progress.capturedFrames);
9422
+ const frameProgress = progress.capturedFrames / progress.totalFrames;
9423
+ reportFrameProgress(
9424
+ job,
9425
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
9426
+ Math.round(25 + frameProgress * 45),
9427
+ onProgress,
9428
+ progress.capturedFrames,
9429
+ progress.totalFrames
9430
+ );
9431
+ },
9432
+ cfg: captureCfg,
9375
9433
  log
9376
9434
  });
9377
- job.framesRendered = i + 1;
9378
- reportFrameProgress(
9379
- job,
9380
- `Layered composite frame ${i + 1}/${job.totalFrames}`,
9381
- Math.round(25 + (i + 1) / totalFrames * 55),
9382
- onProgress,
9383
- i + 1,
9384
- totalFrames
9435
+ captureAttempts.push(...attempts);
9436
+ const lastAttempt = attempts[attempts.length - 1];
9437
+ if (lastAttempt) {
9438
+ workerCount = lastAttempt.workers;
9439
+ }
9440
+ if (probeSession) {
9441
+ captureBeyondViewport = probeSession.options.captureBeyondViewport;
9442
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
9443
+ await closeCaptureSession5(probeSession);
9444
+ probeSession = null;
9445
+ }
9446
+ } else {
9447
+ const sequential = await runSequentialDiskCapture(
9448
+ input,
9449
+ captureOptions,
9450
+ captureCfg,
9451
+ checkDiskProjection
9385
9452
  );
9453
+ probeSession = null;
9454
+ lastBrowserConsole = sequential.lastBrowserConsole;
9455
+ captureBeyondViewport = sequential.captureBeyondViewport;
9456
+ }
9457
+ return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
9458
+ }
9459
+ async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
9460
+ const {
9461
+ fileServer,
9462
+ framesDir,
9463
+ totalFrames,
9464
+ log,
9465
+ createRenderVideoFrameInjector,
9466
+ abortSignal,
9467
+ assertNotAborted,
9468
+ frameRange
9469
+ } = input;
9470
+ let { probeSession } = input;
9471
+ let lastBrowserConsole = [];
9472
+ let captureBeyondViewport;
9473
+ const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
9474
+ const rangeFrames = rangeEnd - rangeStart;
9475
+ const frameExt = frameFileExtension(captureOptions.format);
9476
+ let transientRetriesUsed = 0;
9477
+ let resumeFrom = 0;
9478
+ while (true) {
9479
+ const videoInjector = createRenderVideoFrameInjector();
9480
+ const session = probeSession ?? await createCaptureSession5(
9481
+ fileServer.url,
9482
+ framesDir,
9483
+ captureOptions,
9484
+ videoInjector,
9485
+ captureCfg
9486
+ );
9487
+ captureBeyondViewport = session.options.captureBeyondViewport;
9488
+ try {
9489
+ if (probeSession) {
9490
+ prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
9491
+ probeSession = null;
9492
+ }
9493
+ await ensureSessionReady(session);
9494
+ assertNotAborted();
9495
+ lastBrowserConsole = session.browserConsoleBuffer;
9496
+ await captureSessionFrames(session, {
9497
+ input,
9498
+ rangeStart,
9499
+ rangeEnd,
9500
+ resumeFrom,
9501
+ checkDiskProjection
9502
+ });
9503
+ break;
9504
+ } catch (error) {
9505
+ lastBrowserConsole = session.browserConsoleBuffer;
9506
+ const resume = planTransientResume(error, {
9507
+ abortSignal,
9508
+ rangeFrames,
9509
+ framesDir,
9510
+ frameExt,
9511
+ retriesUsed: transientRetriesUsed
9512
+ });
9513
+ if (resume !== null) {
9514
+ transientRetriesUsed++;
9515
+ resumeFrom = resume;
9516
+ log.warn(
9517
+ "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
9518
+ {
9519
+ resumeFrom,
9520
+ rangeFrames,
9521
+ transientRetriesUsed,
9522
+ error: error instanceof Error ? error.message : String(error)
9523
+ }
9524
+ );
9525
+ continue;
9526
+ }
9527
+ throw wrapCaptureStageError(error, lastBrowserConsole);
9528
+ } finally {
9529
+ lastBrowserConsole = session.browserConsoleBuffer;
9530
+ await closeCaptureSession5(session);
9531
+ }
9532
+ }
9533
+ return { lastBrowserConsole, captureBeyondViewport };
9534
+ }
9535
+ function resolveCaptureRange(frameRange, totalFrames) {
9536
+ return { rangeStart: frameRange?.startFrame ?? 0, rangeEnd: frameRange?.endFrame ?? totalFrames };
9537
+ }
9538
+ function planTransientResume(error, ctx) {
9539
+ const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
9540
+ const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
9541
+ const [firstMissing] = missing;
9542
+ if (!firstMissing) return null;
9543
+ return isTransientCaptureRetryEligible(failure.kind, missing, ctx.retriesUsed) ? firstMissing.startFrame : null;
9544
+ }
9545
+ async function ensureSessionReady(session) {
9546
+ if (!session.isInitialized) {
9547
+ await initializeSession5(session);
9548
+ return;
9549
+ }
9550
+ if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
9551
+ await completeDeferredDrawElementInit3(session);
9552
+ }
9553
+ }
9554
+ async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
9555
+ const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
9556
+ const rangeFrames = rangeEnd - rangeStart;
9557
+ const reportFrame = (fileIndex) => {
9558
+ job.framesRendered = fileIndex + 1;
9559
+ checkDiskProjection(fileIndex + 1);
9560
+ reportFrameProgress(
9561
+ job,
9562
+ `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
9563
+ Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
9564
+ onProgress,
9565
+ fileIndex + 1,
9566
+ rangeFrames
9567
+ );
9568
+ };
9569
+ if (session.workerEncodeEnabled) {
9570
+ let prev = null;
9571
+ const drainPrev = async () => {
9572
+ if (!prev) return;
9573
+ assertNotAborted();
9574
+ const buf = await prev.encodeResult;
9575
+ writeCapturedFrame(session, prev.fileIndex, buf);
9576
+ reportFrame(prev.fileIndex);
9577
+ };
9578
+ for (let i = resumeFrom; i < rangeFrames; i++) {
9579
+ assertNotAborted();
9580
+ const absoluteIdx = rangeStart + i;
9581
+ const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9582
+ const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
9583
+ await drainPrev();
9584
+ prev = { fileIndex: i, encodeResult };
9585
+ }
9586
+ await drainPrev();
9587
+ } else {
9588
+ for (let i = resumeFrom; i < rangeFrames; i++) {
9589
+ assertNotAborted();
9590
+ const absoluteIdx = rangeStart + i;
9591
+ const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9592
+ await captureFrame(session, i, time);
9593
+ reportFrame(i);
9594
+ }
9386
9595
  }
9596
+ await verifyDiskDrawElementSamples(
9597
+ session,
9598
+ {
9599
+ workerId: 0,
9600
+ startFrame: rangeStart + resumeFrom,
9601
+ endFrame: rangeEnd,
9602
+ outputDir: framesDir,
9603
+ outputFrameOffset: rangeStart
9604
+ },
9605
+ false
9606
+ );
9607
+ dedupPerfs.push(getCapturePerfSummary3(session));
9387
9608
  }
9388
9609
 
9389
- // src/services/render/stages/captureHdrHybridLoop.ts
9390
- import { join as join21 } from "node:path";
9610
+ // src/services/render/stages/captureHdrStage.ts
9611
+ import { existsSync as existsSync14, mkdirSync as mkdirSync7 } from "node:fs";
9612
+ import { join as join23 } from "node:path";
9391
9613
  import {
9392
- TRANSITIONS as TRANSITIONS2,
9393
- closeCaptureSession as closeCaptureSession6,
9394
- createCaptureSession as createCaptureSession6,
9395
- createFrameReorderBuffer as createFrameReorderBuffer2,
9396
- crossfade as crossfade2,
9397
- initTransparentBackground,
9398
- initializeSession as initializeSession6
9614
+ closeCaptureSession as closeCaptureSession7,
9615
+ cloneCaptureWarnings as cloneCaptureWarnings2,
9616
+ createCaptureSession as createCaptureSession7,
9617
+ initTransparentBackground as initTransparentBackground2,
9618
+ initializeSession as initializeSession7,
9619
+ spawnStreamingEncoder as spawnStreamingEncoder3
9399
9620
  } from "@hyperframes/engine";
9621
+ import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
9400
9622
 
9401
- // src/services/shaderTransitionWorkerPool.ts
9402
- import { Worker } from "node:worker_threads";
9403
- import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
9404
- import { dirname as dirname7, join as join20 } from "node:path";
9405
- import { createRequire } from "node:module";
9406
- import { existsSync as existsSync13 } from "node:fs";
9407
- import { cpus as cpus2 } from "node:os";
9408
- function resolveWorkerEntry(explicit) {
9409
- if (explicit && explicit.length > 0) {
9410
- return { path: explicit, isTs: explicit.endsWith(".ts") };
9623
+ // src/services/hdrImageTransferCache.ts
9624
+ import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
9625
+ var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
9626
+ function createHdrImageTransferCache(options = {}) {
9627
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
9628
+ if (!Number.isInteger(maxBytes) || maxBytes < 0) {
9629
+ throw new Error(
9630
+ `createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
9631
+ );
9411
9632
  }
9412
- const override = process.env.HF_SHADER_WORKER_ENTRY;
9413
- if (override && override.length > 0) {
9414
- const isTs = override.endsWith(".ts");
9415
- return { path: override, isTs };
9633
+ const entries = /* @__PURE__ */ new Map();
9634
+ let totalBytes = 0;
9635
+ function makeKey(imageId, targetTransfer) {
9636
+ return `${imageId}|${targetTransfer}`;
9416
9637
  }
9417
- const moduleDir = dirname7(fileURLToPath3(import.meta.url));
9418
- const jsPath = join20(moduleDir, "shaderTransitionWorker.js");
9419
- if (existsSync13(jsPath)) return { path: jsPath, isTs: false };
9420
- const tsPath = join20(moduleDir, "shaderTransitionWorker.ts");
9421
- return { path: tsPath, isTs: true };
9422
- }
9423
- function buildExecArgv(entryIsTs) {
9424
- const inherited = [...process.execArgv];
9425
- if (!entryIsTs) return inherited;
9426
- const hasLoader = inherited.some(
9427
- (a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
9428
- );
9429
- if (hasLoader) return inherited;
9430
- try {
9431
- const require2 = createRequire(import.meta.url);
9432
- const tsxEsm = require2.resolve("tsx/esm");
9433
- inherited.push("--import", pathToFileURL(tsxEsm).href);
9434
- } catch {
9638
+ function evictUntilRoom(needed) {
9639
+ while (totalBytes + needed > maxBytes && entries.size > 0) {
9640
+ const lruKey = entries.keys().next().value;
9641
+ if (lruKey === void 0) break;
9642
+ const evicted = entries.get(lruKey);
9643
+ if (evicted) totalBytes -= evicted.byteLength;
9644
+ entries.delete(lruKey);
9645
+ }
9435
9646
  }
9436
- return inherited;
9647
+ return {
9648
+ getConverted(imageId, sourceTransfer, targetTransfer, source) {
9649
+ if (sourceTransfer === targetTransfer) {
9650
+ return source;
9651
+ }
9652
+ if (maxBytes === 0) {
9653
+ const fresh = Buffer.from(source);
9654
+ convertTransfer2(fresh, sourceTransfer, targetTransfer);
9655
+ return fresh;
9656
+ }
9657
+ const key = makeKey(imageId, targetTransfer);
9658
+ const existing = entries.get(key);
9659
+ if (existing) {
9660
+ entries.delete(key);
9661
+ entries.set(key, existing);
9662
+ return existing;
9663
+ }
9664
+ const converted = Buffer.from(source);
9665
+ convertTransfer2(converted, sourceTransfer, targetTransfer);
9666
+ if (converted.byteLength > maxBytes) {
9667
+ return converted;
9668
+ }
9669
+ evictUntilRoom(converted.byteLength);
9670
+ entries.set(key, converted);
9671
+ totalBytes += converted.byteLength;
9672
+ return converted;
9673
+ },
9674
+ size() {
9675
+ return entries.size;
9676
+ },
9677
+ bytesUsed() {
9678
+ return totalBytes;
9679
+ }
9680
+ };
9681
+ }
9682
+
9683
+ // src/services/render/stages/captureHdrSequentialLoop.ts
9684
+ import { join as join20 } from "node:path";
9685
+ import {
9686
+ TRANSITIONS,
9687
+ crossfade
9688
+ } from "@hyperframes/engine";
9689
+ async function runSequentialLayeredFrameLoop(input) {
9690
+ const {
9691
+ job,
9692
+ log,
9693
+ width,
9694
+ height,
9695
+ totalFrames,
9696
+ nativeHdrIds,
9697
+ nativeHdrImageIds,
9698
+ hdrCompositeCtx,
9699
+ hdrPerf,
9700
+ hdrEncoder,
9701
+ domSession,
9702
+ transitionRanges,
9703
+ sceneElements,
9704
+ compositeTransfer,
9705
+ hdrTargetTransfer,
9706
+ hdrVideoEndTimes,
9707
+ cleanedUpVideos,
9708
+ hdrVideoFrameSources,
9709
+ debugDumpEnabled,
9710
+ debugDumpDir,
9711
+ assertNotAborted,
9712
+ onProgress
9713
+ } = input;
9714
+ const beforeCaptureHook = domSession.onBeforeCapture;
9715
+ const bufSize = width * height * 6;
9716
+ const hasTransitions = transitionRanges.length > 0;
9717
+ const transitionBuffers = hasTransitions ? {
9718
+ bufferA: Buffer.alloc(bufSize),
9719
+ bufferB: Buffer.alloc(bufSize),
9720
+ output: Buffer.alloc(bufSize)
9721
+ } : null;
9722
+ const normalCanvas = Buffer.alloc(bufSize);
9723
+ for (let i = 0; i < totalFrames; i++) {
9724
+ assertNotAborted();
9725
+ const time = i * job.config.fps.den / job.config.fps.num;
9726
+ if (hdrPerf) hdrPerf.frames += 1;
9727
+ const stackingInfo = await seekInjectAndQueryStacking(
9728
+ domSession.page,
9729
+ time,
9730
+ beforeCaptureHook,
9731
+ nativeHdrIds,
9732
+ hdrPerf,
9733
+ "frameSeekMs",
9734
+ "frameInjectMs",
9735
+ "stackingQueryMs"
9736
+ );
9737
+ const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
9738
+ if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
9739
+ const hdrEl = stackingInfo.find((e) => e.isHdr);
9740
+ log.debug("[Render] HDR layer composite frame", {
9741
+ frame: i,
9742
+ time: time.toFixed(2),
9743
+ hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
9744
+ stackingCount: stackingInfo.length,
9745
+ activeTransition: activeTransition?.shader
9746
+ });
9747
+ }
9748
+ if (activeTransition && transitionBuffers) {
9749
+ if (hdrPerf) hdrPerf.transitionFrames += 1;
9750
+ const transitionTimingStart = Date.now();
9751
+ const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
9752
+ const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
9753
+ const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
9754
+ timeHdrPhase(hdrPerf, "canvasClearMs", () => {
9755
+ transitionBuffers.bufferA.fill(0);
9756
+ transitionBuffers.bufferB.fill(0);
9757
+ });
9758
+ const sceneCaptures = [
9759
+ [transitionBuffers.bufferA, sceneAIds],
9760
+ [transitionBuffers.bufferB, sceneBIds]
9761
+ ];
9762
+ for (const [sceneBuf, sceneIds] of sceneCaptures) {
9763
+ assertNotAborted();
9764
+ await captureSceneIntoBuffer({
9765
+ session: domSession,
9766
+ sceneBuf,
9767
+ sceneIds,
9768
+ stackingInfo,
9769
+ time,
9770
+ width,
9771
+ height,
9772
+ nativeHdrIds,
9773
+ nativeHdrImageIds,
9774
+ beforeCaptureHook,
9775
+ hdrCompositeCtx,
9776
+ compositeTransfer,
9777
+ hdrTargetTransfer,
9778
+ hdrPerf,
9779
+ log,
9780
+ frameIdx: i
9781
+ });
9782
+ }
9783
+ const shaderName = activeTransition.shader;
9784
+ const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
9785
+ transitionFn(
9786
+ transitionBuffers.bufferA,
9787
+ transitionBuffers.bufferB,
9788
+ transitionBuffers.output,
9789
+ width,
9790
+ height,
9791
+ progress
9792
+ );
9793
+ addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
9794
+ await timeHdrPhaseAsync(
9795
+ hdrPerf,
9796
+ "encoderWriteMs",
9797
+ async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
9798
+ );
9799
+ } else {
9800
+ if (hdrPerf) hdrPerf.normalFrames += 1;
9801
+ timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
9802
+ await timeHdrPhaseAsync(
9803
+ hdrPerf,
9804
+ "normalCompositeMs",
9805
+ () => compositeHdrFrame(hdrCompositeCtx, normalCanvas, time, stackingInfo, void 0, i)
9806
+ );
9807
+ if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9808
+ writeFileExclusiveSync(
9809
+ join20(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9810
+ normalCanvas
9811
+ );
9812
+ }
9813
+ await timeHdrPhaseAsync(
9814
+ hdrPerf,
9815
+ "encoderWriteMs",
9816
+ async () => ensureFrameWritten(await hdrEncoder.writeFrame(normalCanvas), i, hdrEncoder)
9817
+ );
9818
+ }
9819
+ cleanupEndedHdrVideos({
9820
+ time,
9821
+ activeTransition,
9822
+ hdrVideoEndTimes,
9823
+ cleanedUpVideos,
9824
+ hdrVideoFrameSources,
9825
+ sceneElements,
9826
+ log
9827
+ });
9828
+ job.framesRendered = i + 1;
9829
+ reportFrameProgress(
9830
+ job,
9831
+ `Layered composite frame ${i + 1}/${job.totalFrames}`,
9832
+ Math.round(25 + (i + 1) / totalFrames * 55),
9833
+ onProgress,
9834
+ i + 1,
9835
+ totalFrames
9836
+ );
9837
+ }
9838
+ }
9839
+
9840
+ // src/services/render/stages/captureHdrHybridLoop.ts
9841
+ import { join as join22 } from "node:path";
9842
+ import {
9843
+ TRANSITIONS as TRANSITIONS2,
9844
+ closeCaptureSession as closeCaptureSession6,
9845
+ createCaptureSession as createCaptureSession6,
9846
+ createFrameReorderBuffer as createFrameReorderBuffer2,
9847
+ crossfade as crossfade2,
9848
+ initTransparentBackground,
9849
+ initializeSession as initializeSession6
9850
+ } from "@hyperframes/engine";
9851
+
9852
+ // src/services/shaderTransitionWorkerPool.ts
9853
+ import { Worker } from "node:worker_threads";
9854
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
9855
+ import { dirname as dirname8, join as join21 } from "node:path";
9856
+ import { createRequire } from "node:module";
9857
+ import { existsSync as existsSync13 } from "node:fs";
9858
+ import { cpus as cpus2 } from "node:os";
9859
+ function resolveWorkerEntry(explicit) {
9860
+ if (explicit && explicit.length > 0) {
9861
+ return { path: explicit, isTs: explicit.endsWith(".ts") };
9862
+ }
9863
+ const override = process.env.HF_SHADER_WORKER_ENTRY;
9864
+ if (override && override.length > 0) {
9865
+ const isTs = override.endsWith(".ts");
9866
+ return { path: override, isTs };
9867
+ }
9868
+ const moduleDir = dirname8(fileURLToPath3(import.meta.url));
9869
+ const jsPath = join21(moduleDir, "shaderTransitionWorker.js");
9870
+ if (existsSync13(jsPath)) return { path: jsPath, isTs: false };
9871
+ const tsPath = join21(moduleDir, "shaderTransitionWorker.ts");
9872
+ return { path: tsPath, isTs: true };
9873
+ }
9874
+ function buildExecArgv(entryIsTs) {
9875
+ const inherited = [...process.execArgv];
9876
+ if (!entryIsTs) return inherited;
9877
+ const hasLoader = inherited.some(
9878
+ (a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
9879
+ );
9880
+ if (hasLoader) return inherited;
9881
+ try {
9882
+ const require2 = createRequire(import.meta.url);
9883
+ const tsxEsm = require2.resolve("tsx/esm");
9884
+ inherited.push("--import", pathToFileURL(tsxEsm).href);
9885
+ } catch {
9886
+ }
9887
+ return inherited;
9437
9888
  }
9438
9889
  async function createShaderTransitionWorkerPool(opts) {
9439
9890
  const cpuCount = Math.max(1, cpus2().length);
@@ -9785,7 +10236,7 @@ async function runHybridLayeredFrameLoop(input) {
9785
10236
  );
9786
10237
  if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9787
10238
  writeFileExclusiveSync(
9788
- join21(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
10239
+ join22(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9789
10240
  canvas
9790
10241
  );
9791
10242
  }
@@ -9971,7 +10422,7 @@ async function runCaptureHdrStage(input) {
9971
10422
  if (hdrVideoFrameSources.has(v.id)) hdrVideoEndTimes.set(v.id, v.end);
9972
10423
  }
9973
10424
  const debugDumpEnabled = process.env.KEEP_TEMP === "1";
9974
- const debugDumpDir = debugDumpEnabled ? join22(framesDir, "debug-composite") : null;
10425
+ const debugDumpDir = debugDumpEnabled ? join23(framesDir, "debug-composite") : null;
9975
10426
  if (debugDumpDir && !existsSync14(debugDumpDir)) {
9976
10427
  mkdirSync7(debugDumpDir, { recursive: true });
9977
10428
  }
@@ -10101,832 +10552,405 @@ async function runCaptureHdrStage(input) {
10101
10552
  });
10102
10553
  }
10103
10554
  }
10104
- if (!domSessionClosed) {
10105
- await closeCaptureSession7(domSession).catch((err) => {
10106
- log.warn("closeCaptureSession defensive close failed", {
10107
- err: err instanceof Error ? err.message : String(err)
10108
- });
10109
- });
10110
- }
10111
- for (const frameSource of hdrVideoFrameSources.values()) {
10112
- cleanupHdrVideoFrameSource(frameSource, log);
10113
- }
10114
- hdrVideoFrameSources.clear();
10115
- releaseHdrExtractionReservation?.();
10116
- releaseHdrExtractionReservation = null;
10117
- }
10118
- return {
10119
- lastBrowserConsole,
10120
- hdrPerf,
10121
- captureDurationMs,
10122
- encodeMs,
10123
- warnings: cloneCaptureWarnings2(domSession.warnings)
10124
- };
10125
- }
10126
-
10127
- // src/services/render/stages/encodeStage.ts
10128
- import {
10129
- copyFileSync as copyFileSync3,
10130
- existsSync as existsSync15,
10131
- mkdirSync as mkdirSync8,
10132
- readdirSync as readdirSync4,
10133
- readFileSync as readFileSync7,
10134
- rmSync as rmSync7,
10135
- statSync as statSync5,
10136
- writeFileSync as writeFileSync4
10137
- } from "node:fs";
10138
- import { readFile as readFile2 } from "node:fs/promises";
10139
- import { dirname as dirname8, join as join24 } from "node:path";
10140
- import {
10141
- encodeFramesChunkedConcat,
10142
- encodeFramesFromDir,
10143
- formatFfmpegError,
10144
- MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10145
- resolveConfig,
10146
- runFfmpeg as runFfmpeg3,
10147
- spawnStreamingEncoder as spawnStreamingEncoder4
10148
- } from "@hyperframes/engine";
10149
- import {
10150
- clearGifFramesBeforeNext,
10151
- gifClearsAfterLeavingFrameInPlace
10152
- } from "@hyperframes/core";
10153
-
10154
- // src/services/render/stages/gifEncodeArgs.ts
10155
- import { join as join23 } from "node:path";
10156
- function fpsToFfmpegArg2(fps) {
10157
- return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
10158
- }
10159
- var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
10160
- var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
10161
- var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
10162
- function framesInput(input, fpsArg) {
10163
- return [
10164
- ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
10165
- "-framerate",
10166
- fpsArg,
10167
- ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
10168
- "-i",
10169
- join23(input.framesDir, input.framePattern)
10170
- ];
10171
- }
10172
- function buildGifPalettegenArgs(input) {
10173
- const fpsArg = fpsToFfmpegArg2(input.fps);
10174
- const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
10175
- return [
10176
- "-y",
10177
- ...framesInput(input, fpsArg),
10178
- "-vf",
10179
- `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
10180
- input.palettePath
10181
- ];
10182
- }
10183
- function buildGifPaletteuseArgs(input) {
10184
- const fpsArg = fpsToFfmpegArg2(input.fps);
10185
- const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
10186
- return [
10187
- "-y",
10188
- ...framesInput(input, fpsArg),
10189
- "-i",
10190
- input.palettePath,
10191
- "-lavfi",
10192
- `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
10193
- "-loop",
10194
- String(input.loop),
10195
- ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
10196
- input.outputPath
10197
- ];
10198
- }
10199
-
10200
- // src/services/render/stages/encodeStage.ts
10201
- import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
10202
- function resolveGifLoop(loop) {
10203
- const resolved = loop ?? 0;
10204
- if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
10205
- throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
10206
- }
10207
- return resolved;
10208
- }
10209
- function gifEncodeFailure(startTime, outputPath, error, failureReason) {
10210
- return {
10211
- success: false,
10212
- outputPath,
10213
- durationMs: Date.now() - startTime,
10214
- framesEncoded: 0,
10215
- fileSize: 0,
10216
- error,
10217
- failureReason
10218
- };
10219
- }
10220
- async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
10221
- const { outputPath } = argsInput;
10222
- const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
10223
- if (!result.success) {
10224
- const error = formatFfmpegError(result.exitCode, result.stderr);
10225
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10226
- }
10227
- const gif = readFileSync7(outputPath);
10228
- if (!clearGifFramesBeforeNext(gif)) {
10229
- const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
10230
- return gifEncodeFailure(startTime, outputPath, error);
10231
- }
10232
- writeFileSync4(outputPath, gif);
10233
- return null;
10234
- }
10235
- async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
10236
- const startTime = Date.now();
10237
- const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
10238
- const frameCount = files.length;
10239
- if (frameCount === 0) {
10240
- return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
10241
- }
10242
- const argsInput = {
10243
- framesDir,
10244
- framePattern,
10245
- palettePath: input.palettePath,
10246
- outputPath,
10247
- fps: input.fps,
10248
- loop: input.loop,
10249
- preserveAlpha: input.preserveAlpha
10250
- };
10251
- try {
10252
- const run = { signal: input.signal, timeout: input.timeout };
10253
- for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
10254
- const result = await runFfmpeg3(args, run);
10255
- if (!result.success) {
10256
- const error = formatFfmpegError(result.exitCode, result.stderr);
10257
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10258
- }
10259
- }
10260
- if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
10261
- const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
10262
- if (failure) return failure;
10263
- }
10264
- const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
10265
- return {
10266
- success: true,
10267
- outputPath,
10268
- durationMs: Date.now() - startTime,
10269
- framesEncoded: frameCount,
10270
- fileSize
10271
- };
10272
- } finally {
10273
- rmSync7(input.palettePath, { force: true });
10274
- }
10275
- }
10276
- function startEncodeProgress(job, stage, onProgress) {
10277
- const total = job.totalFrames ?? 0;
10278
- updateJobStatus(job, "encoding", stage, 75, onProgress, {
10279
- code: "encode",
10280
- ...total > 0 && { done: 0, total }
10281
- });
10282
- return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10283
- }
10284
- function resolveEngineConfig(input) {
10285
- return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10286
- }
10287
- function videoEncoderOptions(input, engineCfg) {
10288
- const { job, preset } = input;
10289
- return {
10290
- fps: job.config.fps,
10291
- width: input.width,
10292
- height: input.height,
10293
- codec: preset.codec,
10294
- preset: preset.preset,
10295
- quality: input.effectiveQuality,
10296
- bitrate: input.effectiveBitrate,
10297
- pixelFormat: preset.pixelFormat,
10298
- vp9CpuUsed: engineCfg.vp9CpuUsed,
10299
- useGpu: job.config.useGpu,
10300
- hdr: preset.hdr,
10301
- // Distributed chunk renders pass these so the encoder writes closed-GOP
10302
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
10303
- // renders leave both undefined → preserves the existing open-GOP output.
10304
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10305
- gopSize: input.gopSize
10306
- };
10307
- }
10308
- function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10309
- 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;
10310
- }
10311
- async function readLandedFrame(workerPath, mergedPath) {
10312
- if (workerPath !== void 0) {
10313
- try {
10314
- return await readFile2(workerPath);
10315
- } catch (error) {
10316
- if (error.code !== "ENOENT") throw error;
10317
- }
10318
- }
10319
- return readFile2(mergedPath);
10320
- }
10321
- function encodeWhileCapturing(input, totalFrames) {
10322
- const engineCfg = resolveEngineConfig(input);
10323
- const stop = new AbortController();
10324
- const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10325
- const landed = /* @__PURE__ */ new Map();
10326
- let captureDone = false;
10327
- let framesFed = 0;
10328
- let wake = null;
10329
- const nudge = () => {
10330
- wake?.();
10331
- wake = null;
10332
- };
10333
- signal.addEventListener("abort", nudge, { once: true });
10334
- let reportFed = null;
10335
- const encoder = spawnStreamingEncoder4(
10336
- input.videoOnlyPath,
10337
- { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10338
- signal,
10339
- engineCfg
10340
- );
10341
- const fed = (async () => {
10342
- const stream = await encoder;
10343
- for (let next = 0; next < totalFrames; next++) {
10344
- while (!landed.has(next) && !captureDone && !signal.aborted) {
10345
- await new Promise((resolve10) => wake = resolve10);
10346
- }
10347
- if (signal.aborted) return;
10348
- const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10349
- const frame = await readLandedFrame(landed.get(next), mergedPath);
10350
- landed.delete(next);
10351
- if (!await stream.writeFrame(frame)) {
10352
- throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10353
- }
10354
- framesFed++;
10355
- reportFed?.(framesFed);
10356
- }
10357
- })().then(
10358
- () => void 0,
10359
- (error) => error ?? new Error("encode failed")
10360
- );
10361
- const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10362
- return {
10363
- onFrameLanded: (frame) => {
10364
- landed.set(frame.index, frame.path);
10365
- nudge();
10366
- },
10367
- finish: async () => {
10368
- const start = Date.now();
10369
- captureDone = true;
10370
- input.log.info("[Render] Encoded frames while capturing", {
10371
- framesFed,
10372
- totalFrames
10373
- });
10374
- const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10375
- reportFed = encoded;
10376
- nudge();
10377
- const failure = await fed;
10378
- const result = await closeEncoder();
10379
- input.assertNotAborted();
10380
- if (failure === void 0 && result?.success) {
10381
- encoded();
10382
- return { encodeMs: Date.now() - start };
10383
- }
10384
- input.log.warn(
10385
- "[Render] Encoding while capturing failed; encoding the frames after capture",
10386
- {
10387
- error: String(failure ?? result?.error)
10388
- }
10389
- );
10390
- return runEncodeStage(input);
10391
- },
10392
- abort: async () => {
10393
- stop.abort();
10394
- await fed;
10395
- await closeEncoder();
10396
- },
10397
- get framesFed() {
10398
- return framesFed;
10399
- }
10400
- };
10401
- }
10402
- async function runEncodeStage(input) {
10403
- const {
10404
- job,
10405
- log,
10406
- outputPath,
10407
- framesDir,
10408
- videoOnlyPath,
10409
- needsAlpha,
10410
- hasAudio,
10411
- audioOutputPath,
10412
- isPngSequence,
10413
- isGif,
10414
- enableChunkedEncode,
10415
- chunkedEncodeSize,
10416
- abortSignal,
10417
- assertNotAborted,
10418
- onProgress
10419
- } = input;
10420
- const stage5Start = Date.now();
10421
- if (isPngSequence) {
10422
- const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
10423
- if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
10424
- const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
10425
- if (captured.length === 0) {
10426
- throw new Error(
10427
- `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
10428
- );
10429
- }
10430
- captured.forEach((name, i) => {
10431
- const dst = join24(outputPath, formatExportFrameName(i, "png"));
10432
- copyFileSync3(join24(framesDir, name), dst);
10433
- });
10434
- if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
10435
- copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
10436
- log.info(
10437
- `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
10438
- );
10439
- }
10440
- encoded();
10441
- return { encodeMs: Date.now() - stage5Start };
10442
- }
10443
- const engineCfg = resolveEngineConfig(input);
10444
- if (isGif) {
10445
- const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10446
- if (hasAudio) {
10447
- log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
10448
- }
10449
- const frameExt2 = frameFileExtension2(input.captureImageFormat);
10450
- const framePattern2 = `frame_%06d.${frameExt2}`;
10451
- const loop = resolveGifLoop(job.config.gifLoop);
10452
- const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
10453
- fps: job.config.fps,
10454
- loop,
10455
- palettePath: join24(dirname8(videoOnlyPath), "gif-palette.png"),
10456
- preserveAlpha: needsAlpha,
10457
- signal: abortSignal,
10458
- timeout: engineCfg.ffmpegEncodeTimeout
10459
- });
10460
- assertNotAborted();
10461
- if (!encodeResult2.success) {
10462
- throw encoderFailureError("Encoding failed", encodeResult2);
10555
+ if (!domSessionClosed) {
10556
+ await closeCaptureSession7(domSession).catch((err) => {
10557
+ log.warn("closeCaptureSession defensive close failed", {
10558
+ err: err instanceof Error ? err.message : String(err)
10559
+ });
10560
+ });
10463
10561
  }
10464
- encoded();
10465
- return { encodeMs: Date.now() - stage5Start };
10466
- }
10467
- const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
10468
- const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
10469
- const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
10470
- const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10471
- const frameExt = frameFileExtension2(input.captureImageFormat);
10472
- const framePattern = `frame_%06d.${frameExt}`;
10473
- const encoderOpts = videoEncoderOptions(input, engineCfg);
10474
- const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10475
- framesDir,
10476
- framePattern,
10477
- videoOnlyPath,
10478
- encoderOpts,
10479
- chunkedEncodeSize,
10480
- abortSignal,
10481
- videoEngineCfg,
10482
- onFramesEncoded
10483
- ) : await encodeFramesFromDir(
10484
- framesDir,
10485
- framePattern,
10486
- videoOnlyPath,
10487
- encoderOpts,
10488
- abortSignal,
10489
- videoEngineCfg,
10490
- onFramesEncoded
10491
- );
10492
- assertNotAborted();
10493
- if (!encodeResult.success) {
10494
- throw encoderFailureError("Encoding failed", encodeResult);
10562
+ for (const frameSource of hdrVideoFrameSources.values()) {
10563
+ cleanupHdrVideoFrameSource(frameSource, log);
10564
+ }
10565
+ hdrVideoFrameSources.clear();
10566
+ releaseHdrExtractionReservation?.();
10567
+ releaseHdrExtractionReservation = null;
10495
10568
  }
10496
- onFramesEncoded();
10497
- return { encodeMs: Date.now() - stage5Start };
10569
+ return {
10570
+ lastBrowserConsole,
10571
+ hdrPerf,
10572
+ captureDurationMs,
10573
+ encodeMs,
10574
+ warnings: cloneCaptureWarnings2(domSession.warnings)
10575
+ };
10498
10576
  }
10499
10577
 
10500
- // src/services/render/stages/assembleStage.ts
10501
- import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
10502
- import { extname as extname3 } from "node:path";
10503
-
10504
- // src/services/render/audioPadTrim.ts
10505
- import { spawn } from "node:child_process";
10506
- import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync8 } from "node:fs";
10507
- import { dirname as dirname9, join as join25 } from "node:path";
10578
+ // src/services/render/stages/encodeStage.ts
10508
10579
  import {
10509
- DEFAULT_CONFIG,
10510
- describeFfmpegFailure as describeFfmpegFailure2,
10511
- encodeAac,
10512
- extractAudioMetadata as extractAudioMetadata3,
10513
- getFfprobeBinary,
10514
- ManagedChildProcess,
10580
+ copyFileSync as copyFileSync3,
10581
+ existsSync as existsSync15,
10582
+ mkdirSync as mkdirSync8,
10583
+ readdirSync as readdirSync4,
10584
+ readFileSync as readFileSync7,
10585
+ rmSync as rmSync9,
10586
+ statSync as statSync5,
10587
+ writeFileSync as writeFileSync4
10588
+ } from "node:fs";
10589
+ import { readFile as readFile2 } from "node:fs/promises";
10590
+ import { dirname as dirname9, join as join24 } from "node:path";
10591
+ import {
10592
+ encodeFramesChunkedConcat,
10593
+ encodeFramesFromDir,
10594
+ formatFfmpegError,
10595
+ MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10596
+ resolveConfig,
10515
10597
  runFfmpeg as runFfmpeg5,
10516
- trackChildProcess
10598
+ spawnStreamingEncoder as spawnStreamingEncoder4
10517
10599
  } from "@hyperframes/engine";
10518
- import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
10519
-
10520
- // src/services/render/audioTruePeak.ts
10521
10600
  import {
10522
- aacSegmentCount,
10523
- describeFfmpegFailure,
10524
- extractAudioMetadata as extractAudioMetadata2,
10525
- runFfmpeg as runFfmpeg4
10526
- } from "@hyperframes/engine";
10527
- var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
10528
- function checkedDbfs(value) {
10529
- if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
10530
- throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
10531
- }
10532
- return value;
10533
- }
10534
- async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
10535
- let firstBlock;
10536
- let laterBlocks = Number.NEGATIVE_INFINITY;
10537
- let partialLine = "";
10538
- const readLines = (chunk) => {
10539
- const lines = (partialLine + chunk).split(/\r|\n/);
10540
- partialLine = lines.pop() ?? "";
10541
- for (const line of lines) {
10542
- const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
10543
- if (!ftpk) continue;
10544
- const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
10545
- if (firstBlock === void 0) firstBlock = block;
10546
- else laterBlocks = Math.max(laterBlocks, block);
10547
- }
10548
- };
10549
- const result = await runFfmpeg4(
10550
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
10551
- { signal, timeout: timeoutMs, onStderr: readLines }
10552
- );
10553
- if (!result.success) {
10554
- throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
10555
- }
10556
- const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
10557
- return {
10558
- peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
10559
- firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
10560
- laterBlocks: checkedDbfs(laterBlocks)
10561
- };
10562
- }
10563
- async function measureTruePeakDbfs(audioPath, options) {
10564
- const { timeoutMs, signal } = options;
10565
- const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
10566
- const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
10567
- const block = 4800;
10568
- const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
10569
- if (count === 1) return wholeFile();
10570
- const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
10571
- const peers = new AbortController();
10572
- const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
10573
- const scans = await Promise.all(
10574
- Array.from({ length: count }, (_, k) => {
10575
- const start = k === 0 ? 0 : cut(k) - block;
10576
- const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
10577
- return scanTruePeak(
10578
- audioPath,
10579
- `atrim=start_sample=${start}${end},`,
10580
- timeoutMs,
10581
- segmentSignal
10582
- );
10583
- })
10584
- ).catch((error) => {
10585
- peers.abort();
10586
- throw error;
10587
- });
10588
- const last = scans[count - 1];
10589
- const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
10590
- if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
10591
- return wholeFile();
10592
- }
10601
+ clearGifFramesBeforeNext,
10602
+ gifClearsAfterLeavingFrameInPlace
10603
+ } from "@hyperframes/core";
10593
10604
 
10594
- // src/services/render/audioPadTrim.ts
10595
- var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
10596
- var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
10597
- var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
10598
- var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
10599
- function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
10600
- return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
10601
- }
10602
- function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
10603
- const delta = targetDurationSeconds - sourceDurationSeconds;
10604
- const targetSec = formatSeconds(targetDurationSeconds);
10605
- if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
10606
- return {
10607
- operation: "copy",
10608
- steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
10609
- cleanupPaths: []
10610
- };
10611
- }
10612
- if (delta > 0) {
10613
- return {
10614
- operation: "pad",
10615
- steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
10616
- cleanupPaths: []
10617
- };
10618
- }
10619
- return {
10620
- operation: "trim",
10621
- steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
10622
- cleanupPaths: []
10623
- };
10605
+ // src/services/render/stages/gifEncodeArgs.ts
10606
+ import { frameSequencePath } from "@hyperframes/engine";
10607
+ function fpsToFfmpegArg2(fps) {
10608
+ return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
10624
10609
  }
10625
- function formatSeconds(sec) {
10626
- return sec.toFixed(6);
10610
+ var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
10611
+ var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
10612
+ var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
10613
+ function framesInput(input, fpsArg) {
10614
+ return [
10615
+ ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
10616
+ "-framerate",
10617
+ fpsArg,
10618
+ ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
10619
+ "-i",
10620
+ frameSequencePath(input.framesDir, input.framePattern)
10621
+ ];
10627
10622
  }
10628
- function sanitizeProbeFailure(reason, paths) {
10629
- const message = reason instanceof Error ? reason.message : String(reason);
10630
- const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
10631
- return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
10623
+ function buildGifPalettegenArgs(input) {
10624
+ const fpsArg = fpsToFfmpegArg2(input.fps);
10625
+ const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
10626
+ return [
10627
+ "-y",
10628
+ ...framesInput(input, fpsArg),
10629
+ "-vf",
10630
+ `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
10631
+ input.palettePath
10632
+ ];
10632
10633
  }
10633
- async function padOrTrimAudioToVideoFrameCount(input) {
10634
- const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
10635
- const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
10636
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
10637
- const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
10638
- const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
10639
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
10640
- const [videoResult, audioResult] = await Promise.allSettled([
10641
- probeVideo(input.videoPath),
10642
- probeAudio(input.audioPath, input.signal)
10643
- ]);
10644
- const probePaths = [input.videoPath, input.audioPath, input.outputPath];
10645
- if (videoResult.status === "rejected") {
10646
- return failResult(
10647
- input.outputPath,
10648
- 0,
10649
- audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
10650
- `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
10651
- videoResult.reason,
10652
- probePaths
10653
- )}`
10654
- );
10655
- }
10656
- if (audioResult.status === "rejected") {
10657
- return failResult(
10658
- input.outputPath,
10659
- 0,
10660
- 0,
10661
- `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
10662
- audioResult.reason,
10663
- probePaths
10664
- )}`
10665
- );
10634
+ function buildGifPaletteuseArgs(input) {
10635
+ const fpsArg = fpsToFfmpegArg2(input.fps);
10636
+ const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
10637
+ return [
10638
+ "-y",
10639
+ ...framesInput(input, fpsArg),
10640
+ "-i",
10641
+ input.palettePath,
10642
+ "-lavfi",
10643
+ `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
10644
+ "-loop",
10645
+ String(input.loop),
10646
+ ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
10647
+ input.outputPath
10648
+ ];
10649
+ }
10650
+
10651
+ // src/services/render/stages/encodeStage.ts
10652
+ import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
10653
+ function resolveGifLoop(loop) {
10654
+ const resolved = loop ?? 0;
10655
+ if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
10656
+ throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
10666
10657
  }
10667
- const videoInfo = videoResult.value;
10668
- const audioInfo = audioResult.value;
10669
- if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
10670
- return failResult(
10671
- input.outputPath,
10672
- 0,
10673
- audioInfo.durationSeconds,
10674
- `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
10675
- );
10658
+ return resolved;
10659
+ }
10660
+ function gifEncodeFailure(startTime, outputPath, error, failureReason) {
10661
+ return {
10662
+ success: false,
10663
+ outputPath,
10664
+ durationMs: Date.now() - startTime,
10665
+ framesEncoded: 0,
10666
+ fileSize: 0,
10667
+ error,
10668
+ failureReason
10669
+ };
10670
+ }
10671
+ async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
10672
+ const { outputPath } = argsInput;
10673
+ const result = await runFfmpeg5(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
10674
+ if (!result.success) {
10675
+ const error = formatFfmpegError(result.exitCode, result.stderr);
10676
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10676
10677
  }
10677
- const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
10678
- const plan = buildPadTrimAudioPlan(
10679
- input.audioPath,
10680
- input.outputPath,
10681
- audioInfo.durationSeconds,
10682
- targetDurationSeconds
10683
- );
10684
- try {
10685
- for (const step of plan.steps) {
10686
- const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
10687
- if (!ffmpegResult.success) {
10688
- return {
10689
- success: false,
10690
- outputPath: input.outputPath,
10691
- targetDurationSeconds,
10692
- sourceDurationSeconds: audioInfo.durationSeconds,
10693
- operation: plan.operation,
10694
- error: ffmpegResult.error,
10695
- failureReason: ffmpegResult.failureReason
10696
- };
10697
- }
10698
- }
10699
- } catch (err) {
10700
- return {
10701
- success: false,
10702
- outputPath: input.outputPath,
10703
- targetDurationSeconds,
10704
- sourceDurationSeconds: audioInfo.durationSeconds,
10705
- operation: plan.operation,
10706
- error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
10707
- };
10708
- } finally {
10709
- for (const path of plan.cleanupPaths) rmSync8(path, { force: true });
10678
+ const gif = readFileSync7(outputPath);
10679
+ if (!clearGifFramesBeforeNext(gif)) {
10680
+ const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
10681
+ return gifEncodeFailure(startTime, outputPath, error);
10710
10682
  }
10711
- let loweredDb = 0;
10712
- if (probeTruePeak) {
10713
- const correction = await enforceAacTruePeak({
10714
- audioPath: input.outputPath,
10715
- targetDurationSeconds,
10716
- signal: input.signal,
10717
- probeTruePeak,
10718
- encode
10719
- });
10720
- if (!correction.success) {
10721
- return {
10722
- success: false,
10723
- outputPath: input.outputPath,
10724
- targetDurationSeconds,
10725
- sourceDurationSeconds: audioInfo.durationSeconds,
10726
- operation: plan.operation,
10727
- error: correction.error,
10728
- failureReason: correction.failureReason
10729
- };
10730
- }
10731
- loweredDb = correction.loweredDb ?? 0;
10683
+ writeFileSync4(outputPath, gif);
10684
+ return null;
10685
+ }
10686
+ async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
10687
+ const startTime = Date.now();
10688
+ const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
10689
+ const frameCount = files.length;
10690
+ if (frameCount === 0) {
10691
+ return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
10732
10692
  }
10733
- return {
10734
- success: true,
10735
- outputPath: input.outputPath,
10736
- targetDurationSeconds,
10737
- sourceDurationSeconds: audioInfo.durationSeconds,
10738
- operation: plan.operation,
10739
- ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
10693
+ const argsInput = {
10694
+ framesDir,
10695
+ framePattern,
10696
+ palettePath: input.palettePath,
10697
+ outputPath,
10698
+ fps: input.fps,
10699
+ loop: input.loop,
10700
+ preserveAlpha: input.preserveAlpha
10740
10701
  };
10741
- }
10742
- async function enforceAacTruePeak(input) {
10743
- let scratchDir;
10744
10702
  try {
10745
- scratchDir = mkdtempSync3(join25(dirname9(input.audioPath), ".true-peak-"));
10746
- const correctedPath = join25(scratchDir, "audio.m4a");
10747
- const measurements = [];
10748
- let attenuationDb = 0;
10749
- let measuredPath = input.audioPath;
10750
- for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
10751
- const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
10752
- measurements.push(
10753
- `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
10754
- );
10755
- if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
10756
- return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
10757
- }
10758
- if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
10759
- if (measuredPath !== correctedPath) return { success: true };
10760
- renameSync4(correctedPath, input.audioPath);
10761
- return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
10762
- }
10763
- if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
10764
- const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
10765
- attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
10766
- const result = await input.encode(
10767
- input.audioPath,
10768
- correctedPath,
10769
- input.targetDurationSeconds,
10770
- aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
10771
- );
10703
+ const run = { signal: input.signal, timeout: input.timeout };
10704
+ for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
10705
+ const result = await runFfmpeg5(args, run);
10772
10706
  if (!result.success) {
10773
- return {
10774
- success: false,
10775
- error: sanitizeProbeFailure(
10776
- result.error ?? "audioPadTrim: failed to constrain AAC true peak",
10777
- [input.audioPath, correctedPath]
10778
- ),
10779
- failureReason: result.failureReason
10780
- };
10707
+ const error = formatFfmpegError(result.exitCode, result.stderr);
10708
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10781
10709
  }
10782
- measuredPath = correctedPath;
10783
10710
  }
10711
+ if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
10712
+ const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
10713
+ if (failure) return failure;
10714
+ }
10715
+ const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
10784
10716
  return {
10785
- success: false,
10786
- error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
10787
- };
10788
- } catch (err) {
10789
- return {
10790
- success: false,
10791
- error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
10792
- err,
10793
- scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
10794
- )}`
10717
+ success: true,
10718
+ outputPath,
10719
+ durationMs: Date.now() - startTime,
10720
+ framesEncoded: frameCount,
10721
+ fileSize
10795
10722
  };
10796
10723
  } finally {
10797
- if (scratchDir) rmSync8(scratchDir, { recursive: true, force: true });
10724
+ rmSync9(input.palettePath, { force: true });
10798
10725
  }
10799
10726
  }
10800
- function failResult(outputPath, target, source, error) {
10801
- return {
10802
- success: false,
10803
- outputPath,
10804
- targetDurationSeconds: target,
10805
- sourceDurationSeconds: source,
10806
- operation: "copy",
10807
- error
10808
- };
10809
- }
10810
- async function defaultProbeVideoFrameInfo(videoPath, signal) {
10811
- const fastInfo = await runFfprobeJson(
10812
- [
10813
- "-v",
10814
- "error",
10815
- "-select_streams",
10816
- "v:0",
10817
- "-show_entries",
10818
- "stream=nb_frames,r_frame_rate",
10819
- "-of",
10820
- "json",
10821
- "--",
10822
- videoPath
10823
- ],
10824
- signal
10825
- );
10826
- let stream = fastInfo.streams?.[0];
10827
- const fastCount = Number(stream?.nb_frames);
10828
- if (stream && Number.isFinite(fastCount) && fastCount > 0) {
10829
- return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
10830
- }
10831
- const slowInfo = await runFfprobeJson(
10832
- [
10833
- "-v",
10834
- "error",
10835
- "-select_streams",
10836
- "v:0",
10837
- "-count_packets",
10838
- "-show_entries",
10839
- "stream=nb_read_packets,r_frame_rate",
10840
- "-of",
10841
- "json",
10842
- "--",
10843
- videoPath
10844
- ],
10845
- signal
10846
- );
10847
- stream = slowInfo.streams?.[0];
10848
- if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
10849
- const slowCount = Number(stream.nb_read_packets);
10850
- if (!Number.isFinite(slowCount) || slowCount <= 0) {
10851
- throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
10852
- }
10853
- return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
10727
+ function startEncodeProgress(job, stage, onProgress) {
10728
+ const total = job.totalFrames ?? 0;
10729
+ updateJobStatus(job, "encoding", stage, 75, onProgress, {
10730
+ code: "encode",
10731
+ ...total > 0 && { done: 0, total }
10732
+ });
10733
+ return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10854
10734
  }
10855
- function parseFrameRate(rate) {
10856
- const [n, d] = rate.split("/");
10857
- const fpsNum = Number(n);
10858
- const fpsDen = d === void 0 ? 1 : Number(d);
10859
- if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
10860
- throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
10861
- }
10862
- return { fpsNum, fpsDen };
10735
+ function resolveEngineConfig(input) {
10736
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10863
10737
  }
10864
- async function defaultProbeAudioInfo(audioPath, signal) {
10865
- const metadata = await extractAudioMetadata3(audioPath, { signal });
10738
+ function videoEncoderOptions(input, engineCfg) {
10739
+ const { job, preset } = input;
10866
10740
  return {
10867
- durationSeconds: metadata.durationSeconds,
10868
- sampleRate: metadata.sampleRate,
10869
- channels: metadata.channels,
10870
- audioCodec: metadata.audioCodec
10741
+ fps: job.config.fps,
10742
+ width: input.width,
10743
+ height: input.height,
10744
+ codec: preset.codec,
10745
+ preset: preset.preset,
10746
+ quality: input.effectiveQuality,
10747
+ bitrate: input.effectiveBitrate,
10748
+ pixelFormat: preset.pixelFormat,
10749
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
10750
+ useGpu: job.config.useGpu,
10751
+ hdr: preset.hdr,
10752
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
10753
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
10754
+ // renders leave both undefined → preserves the existing open-GOP output.
10755
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10756
+ gopSize: input.gopSize
10871
10757
  };
10872
10758
  }
10873
- async function defaultRunFfmpeg(args, timeoutMs, signal) {
10874
- return toStepResult(await runFfmpeg5(args, { signal, timeout: timeoutMs }), timeoutMs);
10759
+ function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10760
+ 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;
10875
10761
  }
10876
- function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
10877
- return async (inputPath, outputPath, durationSeconds, filter) => {
10878
- const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
10879
- return toStepResult(
10880
- await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
10881
- timeoutMs
10882
- );
10883
- };
10762
+ async function readLandedFrame(workerPath, mergedPath) {
10763
+ if (workerPath !== void 0) {
10764
+ try {
10765
+ return await readFile2(workerPath);
10766
+ } catch (error) {
10767
+ if (error.code !== "ENOENT") throw error;
10768
+ }
10769
+ }
10770
+ return readFile2(mergedPath);
10884
10771
  }
10885
- function toStepResult(result, timeoutMs) {
10886
- if (result.success) return { success: true };
10772
+ function encodeWhileCapturing(input, totalFrames) {
10773
+ const engineCfg = resolveEngineConfig(input);
10774
+ const stop = new AbortController();
10775
+ const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10776
+ const landed = /* @__PURE__ */ new Map();
10777
+ let captureDone = false;
10778
+ let framesFed = 0;
10779
+ let wake = null;
10780
+ const nudge = () => {
10781
+ wake?.();
10782
+ wake = null;
10783
+ };
10784
+ signal.addEventListener("abort", nudge, { once: true });
10785
+ let reportFed = null;
10786
+ const encoder = spawnStreamingEncoder4(
10787
+ input.videoOnlyPath,
10788
+ { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10789
+ signal,
10790
+ engineCfg
10791
+ );
10792
+ const fed = (async () => {
10793
+ const stream = await encoder;
10794
+ for (let next = 0; next < totalFrames; next++) {
10795
+ while (!landed.has(next) && !captureDone && !signal.aborted) {
10796
+ await new Promise((resolve10) => wake = resolve10);
10797
+ }
10798
+ if (signal.aborted) return;
10799
+ const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10800
+ const frame = await readLandedFrame(landed.get(next), mergedPath);
10801
+ landed.delete(next);
10802
+ if (!await stream.writeFrame(frame)) {
10803
+ throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10804
+ }
10805
+ framesFed++;
10806
+ reportFed?.(framesFed);
10807
+ }
10808
+ })().then(
10809
+ () => void 0,
10810
+ (error) => error ?? new Error("encode failed")
10811
+ );
10812
+ const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10887
10813
  return {
10888
- success: false,
10889
- error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
10890
- failureReason: result.failureReason
10814
+ onFrameLanded: (frame) => {
10815
+ landed.set(frame.index, frame.path);
10816
+ nudge();
10817
+ },
10818
+ finish: async () => {
10819
+ const start = Date.now();
10820
+ captureDone = true;
10821
+ input.log.info("[Render] Encoded frames while capturing", {
10822
+ framesFed,
10823
+ totalFrames
10824
+ });
10825
+ const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10826
+ reportFed = encoded;
10827
+ nudge();
10828
+ const failure = await fed;
10829
+ const result = await closeEncoder();
10830
+ input.assertNotAborted();
10831
+ if (failure === void 0 && result?.success) {
10832
+ encoded();
10833
+ return { encodeMs: Date.now() - start };
10834
+ }
10835
+ input.log.warn(
10836
+ "[Render] Encoding while capturing failed; encoding the frames after capture",
10837
+ {
10838
+ error: String(failure ?? result?.error)
10839
+ }
10840
+ );
10841
+ return runEncodeStage(input);
10842
+ },
10843
+ abort: async () => {
10844
+ stop.abort();
10845
+ await fed;
10846
+ await closeEncoder();
10847
+ },
10848
+ get framesFed() {
10849
+ return framesFed;
10850
+ }
10891
10851
  };
10892
10852
  }
10893
- async function runFfprobeJson(args, signal) {
10894
- if (!args.includes("--")) {
10895
- throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
10896
- }
10897
- const proc = spawn(getFfprobeBinary(), args, {
10898
- stdio: ["ignore", "pipe", "pipe"],
10899
- windowsHide: true
10900
- });
10901
- trackChildProcess(proc);
10902
- let stdout = "";
10903
- proc.stdout.on("data", (data) => {
10904
- stdout += data.toString();
10905
- });
10906
- const managed = new ManagedChildProcess(proc, {
10907
- signal,
10908
- deadlineAtMs: Date.now() + 3e4
10909
- });
10910
- const outcome = await managed.wait();
10911
- if (outcome.reason === "spawn_error") {
10912
- if (outcome.error?.code === "ENOENT") {
10913
- throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
10853
+ async function runEncodeStage(input) {
10854
+ const {
10855
+ job,
10856
+ log,
10857
+ outputPath,
10858
+ framesDir,
10859
+ videoOnlyPath,
10860
+ needsAlpha,
10861
+ hasAudio,
10862
+ audioOutputPath,
10863
+ isPngSequence,
10864
+ isGif,
10865
+ enableChunkedEncode,
10866
+ chunkedEncodeSize,
10867
+ abortSignal,
10868
+ assertNotAborted,
10869
+ onProgress
10870
+ } = input;
10871
+ const stage5Start = Date.now();
10872
+ if (isPngSequence) {
10873
+ const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
10874
+ if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
10875
+ const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
10876
+ if (captured.length === 0) {
10877
+ throw new Error(
10878
+ `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
10879
+ );
10914
10880
  }
10915
- throw outcome.error ?? new Error(outcome.stderr);
10881
+ captured.forEach((name, i) => {
10882
+ const dst = join24(outputPath, formatExportFrameName(i, "png"));
10883
+ copyFileSync3(join24(framesDir, name), dst);
10884
+ });
10885
+ if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
10886
+ copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
10887
+ log.info(
10888
+ `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
10889
+ );
10890
+ }
10891
+ encoded();
10892
+ return { encodeMs: Date.now() - stage5Start };
10916
10893
  }
10917
- if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
10918
- const probed = args[args.length - 1];
10919
- const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
10920
- throw new Error(`ffprobe ${outcome.reason}: ${redactTelemetryString3(scrubbed, 2e3)}`);
10894
+ const engineCfg = resolveEngineConfig(input);
10895
+ if (isGif) {
10896
+ const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10897
+ if (hasAudio) {
10898
+ log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
10899
+ }
10900
+ const frameExt2 = frameFileExtension2(input.captureImageFormat);
10901
+ const framePattern2 = `frame_%06d.${frameExt2}`;
10902
+ const loop = resolveGifLoop(job.config.gifLoop);
10903
+ const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
10904
+ fps: job.config.fps,
10905
+ loop,
10906
+ palettePath: join24(dirname9(videoOnlyPath), "gif-palette.png"),
10907
+ preserveAlpha: needsAlpha,
10908
+ signal: abortSignal,
10909
+ timeout: engineCfg.ffmpegEncodeTimeout
10910
+ });
10911
+ assertNotAborted();
10912
+ if (!encodeResult2.success) {
10913
+ throw encoderFailureError("Encoding failed", encodeResult2);
10914
+ }
10915
+ encoded();
10916
+ return { encodeMs: Date.now() - stage5Start };
10921
10917
  }
10922
- try {
10923
- return JSON.parse(stdout);
10924
- } catch (err) {
10925
- throw new Error(`Failed to parse ffprobe output: ${err.message}`);
10918
+ const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
10919
+ const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
10920
+ const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
10921
+ const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10922
+ const frameExt = frameFileExtension2(input.captureImageFormat);
10923
+ const framePattern = `frame_%06d.${frameExt}`;
10924
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
10925
+ const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10926
+ framesDir,
10927
+ framePattern,
10928
+ videoOnlyPath,
10929
+ encoderOpts,
10930
+ chunkedEncodeSize,
10931
+ abortSignal,
10932
+ videoEngineCfg,
10933
+ onFramesEncoded
10934
+ ) : await encodeFramesFromDir(
10935
+ framesDir,
10936
+ framePattern,
10937
+ videoOnlyPath,
10938
+ encoderOpts,
10939
+ abortSignal,
10940
+ videoEngineCfg,
10941
+ onFramesEncoded
10942
+ );
10943
+ assertNotAborted();
10944
+ if (!encodeResult.success) {
10945
+ throw encoderFailureError("Encoding failed", encodeResult);
10926
10946
  }
10947
+ onFramesEncoded();
10948
+ return { encodeMs: Date.now() - stage5Start };
10927
10949
  }
10928
10950
 
10929
10951
  // src/services/render/stages/assembleStage.ts
10952
+ import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
10953
+ import { extname as extname3 } from "node:path";
10930
10954
  function recordLimiterAttenuation(job, audioLoweredDb) {
10931
10955
  if (audioLoweredDb === void 0) return;
10932
10956
  job.audioLoweredDb = audioLoweredDb;
@@ -11057,6 +11081,10 @@ function assertMotionBlurSupported(motionBlur, route) {
11057
11081
  `[MotionBlur] sub-frame motion blur is not supported on the "${route}" capture route: ${reason}`
11058
11082
  );
11059
11083
  }
11084
+ function pageMotionBlurForRoute(route) {
11085
+ if (route.hasHdrContent) return "hdr-render";
11086
+ return route.useLayeredComposite ? "layered-composite" : void 0;
11087
+ }
11060
11088
 
11061
11089
  // src/services/render/outputScaleRoute.ts
11062
11090
  var UNSUPPORTED_REASON2 = {
@@ -11088,7 +11116,7 @@ function sampleDirectoryBytes(dir) {
11088
11116
  continue;
11089
11117
  }
11090
11118
  for (const name of entries) {
11091
- const full = join26(current, name);
11119
+ const full = join25(current, name);
11092
11120
  try {
11093
11121
  const st = statSync6(full);
11094
11122
  if (st.isDirectory()) {
@@ -11236,7 +11264,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
11236
11264
  const ranges = [];
11237
11265
  let rangeStart = null;
11238
11266
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
11239
- const framePath = join26(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11267
+ const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11240
11268
  const missing = !existsSync16(framePath) || statSync6(framePath).size <= 8;
11241
11269
  if (missing && rangeStart === null) {
11242
11270
  rangeStart = frameIndex;
@@ -11259,7 +11287,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
11259
11287
  workerId,
11260
11288
  startFrame: rangeStart + range.startFrame,
11261
11289
  endFrame: rangeStart + range.endFrame,
11262
- outputDir: join26(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
11290
+ outputDir: join25(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
11263
11291
  outputFrameOffset: rangeStart
11264
11292
  }));
11265
11293
  batches.push(batch);
@@ -11287,8 +11315,8 @@ function getNextRetryWorkerCount(currentWorkers) {
11287
11315
  return Math.max(1, Math.floor(currentWorkers / 2));
11288
11316
  }
11289
11317
  function resolveRenderWorkDirPrefix(outputPath, jobId, platform2 = process.platform, systemTempDir = tmpdir2()) {
11290
- if (platform2 === "win32" || platform2 === "darwin") return join26(systemTempDir, "hf-render-");
11291
- return join26(dirname10(outputPath), `work-${jobId}-`);
11318
+ if (platform2 === "win32" || platform2 === "darwin") return join25(systemTempDir, "hf-render-");
11319
+ return join25(dirname10(outputPath), `work-${jobId}-`);
11292
11320
  }
11293
11321
  var MAX_TRANSIENT_CAPTURE_RETRIES = 1;
11294
11322
  function isTransientCaptureRetryEligible(failureKind, missing, retriesUsed) {
@@ -11320,7 +11348,7 @@ The composition is too large for the available memory. To reduce memory pressure
11320
11348
  function countCapturedFrames(totalFrames, framesDir, frameExt) {
11321
11349
  let captured = 0;
11322
11350
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
11323
- const framePath = join26(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11351
+ const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11324
11352
  if (existsSync16(framePath)) captured++;
11325
11353
  }
11326
11354
  return captured;
@@ -11346,7 +11374,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
11346
11374
  reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
11347
11375
  });
11348
11376
  pendingTransientRetry = false;
11349
- const attemptWorkDir = join26(options.workDir, `capture-attempt-${attempt}`);
11377
+ const attemptWorkDir = join25(options.workDir, `capture-attempt-${attempt}`);
11350
11378
  const batches = missingRanges ? buildMissingFrameRetryBatches(
11351
11379
  missingRanges,
11352
11380
  currentWorkers,
@@ -11915,17 +11943,17 @@ function deVerifyFallbackTelemetry(err) {
11915
11943
  }
11916
11944
  function resolveRenderDebugDir() {
11917
11945
  const producerRoot = process.env.PRODUCER_RENDERS_DIR ? resolve7(process.env.PRODUCER_RENDERS_DIR, "..") : resolve7(dirname10(fileURLToPath4(import.meta.url)), "../..");
11918
- return join26(producerRoot, ".debug");
11946
+ return join25(producerRoot, ".debug");
11919
11947
  }
11920
11948
  async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
11921
11949
  validateHlsRenderConfig(job.config);
11922
11950
  const debugDir = resolveRenderDebugDir();
11923
11951
  const outputDir = dirname10(outputPath);
11924
11952
  if (!existsSync16(outputDir)) mkdirSync9(outputDir, { recursive: true });
11925
- const workDir = job.config.debug ? join26(debugDir, job.id) : createRenderWorkDir(resolveRenderWorkDirPrefix(outputPath, job.id), outputDir);
11953
+ const workDir = job.config.debug ? join25(debugDir, job.id) : createRenderWorkDir(resolveRenderWorkDirPrefix(outputPath, job.id), outputDir);
11926
11954
  const pipelineStart = Date.now();
11927
11955
  const baseLog = job.config.logger ?? defaultLogger;
11928
- const logPath = job.config.debug ? join26(workDir, "render.log") : null;
11956
+ const logPath = job.config.debug ? join25(workDir, "render.log") : null;
11929
11957
  const execution = new RenderExecutionContext({
11930
11958
  request: { renderJobId: job.id, projectDir, outputPath },
11931
11959
  logger: logPath ? createRenderFileLogger(logPath, baseLog) : baseLog,
@@ -11939,7 +11967,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
11939
11967
  log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
11940
11968
  return;
11941
11969
  }
11942
- rmSync9(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
11970
+ rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
11943
11971
  });
11944
11972
  try {
11945
11973
  await executeRenderPipeline({
@@ -11983,7 +12011,7 @@ async function executeRenderPipeline(input) {
11983
12011
  imageDecodeFailures: 0
11984
12012
  };
11985
12013
  let hdrPerf;
11986
- const perfOutputPath = join26(workDir, "perf-summary.json");
12014
+ const perfOutputPath = join25(workDir, "perf-summary.json");
11987
12015
  const cfg = { ...job.config.producerConfig ?? resolveConfig2() };
11988
12016
  const observability = new RenderObservabilityRecorder({
11989
12017
  pipelineStartMs: pipelineStart,
@@ -12082,15 +12110,15 @@ async function executeRenderPipeline(input) {
12082
12110
  requestedWorkers: job.config.workers ?? "auto"
12083
12111
  });
12084
12112
  const entryFile = job.config.entryFile || "index.html";
12085
- let htmlPath = join26(projectDir, entryFile);
12113
+ let htmlPath = join25(projectDir, entryFile);
12086
12114
  if (!existsSync16(htmlPath)) {
12087
12115
  throw new Error(`Entry file not found: ${htmlPath}`);
12088
12116
  }
12089
12117
  assertNotAborted();
12090
12118
  const rawEntry = readFileSync8(htmlPath, "utf-8");
12091
12119
  if (entryFile !== "index.html" && isTemplateEntry(rawEntry)) {
12092
- const wrapperPath = join26(workDir, "standalone-entry.html");
12093
- const projectIndexPath = join26(projectDir, "index.html");
12120
+ const wrapperPath = join25(workDir, "standalone-entry.html");
12121
+ const projectIndexPath = join25(projectDir, "index.html");
12094
12122
  if (!existsSync16(projectIndexPath)) {
12095
12123
  throw new Error(
12096
12124
  `Template entry file "${entryFile}" requires a project index.html to extract its render shell.`
@@ -12130,7 +12158,8 @@ async function executeRenderPipeline(input) {
12130
12158
  needsAlpha,
12131
12159
  log,
12132
12160
  assertNotAborted,
12133
- variables: job.config.variables
12161
+ variables: job.config.variables,
12162
+ pageMotionBlur: job.config.pageMotionBlur
12134
12163
  })
12135
12164
  );
12136
12165
  let compiled = compileResult.compiled;
@@ -12252,7 +12281,7 @@ async function executeRenderPipeline(input) {
12252
12281
  updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress, {
12253
12282
  code: "extract_video"
12254
12283
  });
12255
- const compiledDir = join26(workDir, "compiled");
12284
+ const compiledDir = join25(workDir, "compiled");
12256
12285
  const extractResult = await observeRenderStage(
12257
12286
  observability,
12258
12287
  "video_extract",
@@ -12340,7 +12369,8 @@ async function executeRenderPipeline(input) {
12340
12369
  projectDir,
12341
12370
  workDir,
12342
12371
  compiledDir,
12343
- duration: probeResult.duration,
12372
+ totalFrames: probeResult.totalFrames,
12373
+ fps: job.config.fps,
12344
12374
  ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
12345
12375
  audioGain: cfg.audioGain,
12346
12376
  audios: composition.audios,
@@ -12352,6 +12382,7 @@ async function executeRenderPipeline(input) {
12352
12382
  );
12353
12383
  const { audioOutputPath, hasAudio } = audioResult;
12354
12384
  perfStages.audioProcessMs = audioResult.audioProcessMs;
12385
+ recordLimiterAttenuation(job, audioResult.audioLoweredDb);
12355
12386
  if (audioResult.audioError) {
12356
12387
  const audioFailures = audioResult.audioFailures ?? [];
12357
12388
  const failureOwner = audioFailures.length === 0 ? void 0 : audioFailures.some((failure) => failure.owner === "system") ? "system" : "user";
@@ -12384,7 +12415,7 @@ async function executeRenderPipeline(input) {
12384
12415
  try {
12385
12416
  fileServer = await createFileServer({
12386
12417
  projectDir,
12387
- compiledDir: join26(workDir, "compiled"),
12418
+ compiledDir: join25(workDir, "compiled"),
12388
12419
  port: 0,
12389
12420
  preHeadScripts: [VIRTUAL_TIME_SHIM],
12390
12421
  fps: job.config.fps
@@ -12402,7 +12433,7 @@ async function executeRenderPipeline(input) {
12402
12433
  if (!activeFileServer) {
12403
12434
  throw new Error("File server failed to initialize before frame capture");
12404
12435
  }
12405
- const framesDir = join26(workDir, "captured-frames");
12436
+ const framesDir = join25(workDir, "captured-frames");
12406
12437
  if (!existsSync16(framesDir)) mkdirSync9(framesDir, { recursive: true });
12407
12438
  updateJobStatus(job, "rendering", "Checking browser GPU", 25, onProgress, {
12408
12439
  code: "start_browsers"
@@ -12434,14 +12465,17 @@ async function executeRenderPipeline(input) {
12434
12465
  variables: job.config.variables,
12435
12466
  deviceScaleFactor,
12436
12467
  motionBlur: job.config.motionBlur,
12468
+ pageMotionBlur: job.config.pageMotionBlur,
12437
12469
  ...videoCaptureBeyondViewport !== void 0 ? { captureBeyondViewport: videoCaptureBeyondViewport } : {}
12438
12470
  };
12439
12471
  resolvedCaptureBeyondViewport = captureOptions.captureBeyondViewport ?? resolvedCaptureBeyondViewport;
12440
12472
  if (resolvedCaptureBeyondViewport !== void 0) {
12441
12473
  updateCaptureObservability({ captureBeyondViewport: resolvedCaptureBeyondViewport });
12442
12474
  }
12475
+ let routePageMotionBlur;
12443
12476
  const buildCaptureOptions = () => ({
12444
12477
  ...captureOptions,
12478
+ ...routePageMotionBlur ? { pageMotionBlur: routePageMotionBlur } : {},
12445
12479
  videoMetadataHints,
12446
12480
  skipReadinessVideoIds: videoReadinessSkipIds,
12447
12481
  subTimelineWaitMemo,
@@ -12475,6 +12509,7 @@ async function executeRenderPipeline(input) {
12475
12509
  const nativeHdrIds = /* @__PURE__ */ new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
12476
12510
  const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0);
12477
12511
  if (hasHdrContent) assertOutputScaleSupported(deviceScaleFactor, "hdr_layered");
12512
+ routePageMotionBlur = pageMotionBlurForRoute({ hasHdrContent, useLayeredComposite: false });
12478
12513
  const deSingleMinFramesRaw = process.env.HF_DE_SINGLE_MIN_FRAMES;
12479
12514
  const deSingleMinFramesNum = deSingleMinFramesRaw === void 0 || deSingleMinFramesRaw.trim() === "" ? 900 : Number(deSingleMinFramesRaw);
12480
12515
  const deSingleMinFrames = Number.isFinite(deSingleMinFramesNum) ? deSingleMinFramesNum : 900;
@@ -12848,7 +12883,7 @@ async function executeRenderPipeline(input) {
12848
12883
  hls: ".mp4"
12849
12884
  };
12850
12885
  const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
12851
- const videoOnlyPath = join26(workDir, `video-only${videoExt}`);
12886
+ const videoOnlyPath = join25(workDir, `video-only${videoExt}`);
12852
12887
  const usePageSideCompositingForTransitions = (cfg.enablePageSideCompositing || isGif) && compiled.hasShaderTransitions && !hasHdrContent && outputSupportsPageSideShaderCompositing(outputFormat);
12853
12888
  if (usePageSideCompositingForTransitions) {
12854
12889
  activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
@@ -12877,6 +12912,7 @@ async function executeRenderPipeline(input) {
12877
12912
  hasShaderTransitions: compiled.hasShaderTransitions && !isGif,
12878
12913
  isPngSequence
12879
12914
  });
12915
+ routePageMotionBlur = pageMotionBlurForRoute({ hasHdrContent, useLayeredComposite });
12880
12916
  const inversionFallback = resolveInversionRetryPlan({
12881
12917
  deWorkerInversion,
12882
12918
  preInversionWorkerCount: preRoutingWorkerCount,
@@ -13090,9 +13126,10 @@ async function executeRenderPipeline(input) {
13090
13126
  outputWidth: captureCompositionWidth ?? width,
13091
13127
  outputHeight: captureCompositionHeight ?? height,
13092
13128
  motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "",
13129
+ pageMotionBlur: job.config.pageMotionBlur,
13093
13130
  captureQuality: captureOptions.quality
13094
13131
  });
13095
- const segmentDir = segmentDirFor(join26(projectDir, "renders"), segmentPlanHash);
13132
+ const segmentDir = segmentDirFor(join25(projectDir, "renders"), segmentPlanHash);
13096
13133
  let completedSegments = /* @__PURE__ */ new Set();
13097
13134
  if (job.config.resumeSegments === true) {
13098
13135
  const existing = readSegmentManifest(segmentDir);
@@ -13107,10 +13144,10 @@ async function executeRenderPipeline(input) {
13107
13144
  });
13108
13145
  }
13109
13146
  if (completedSegments.size === 0) {
13110
- rmSync9(segmentDir, { recursive: true, force: true });
13147
+ rmSync10(segmentDir, { recursive: true, force: true });
13111
13148
  }
13112
13149
  } else {
13113
- rmSync9(segmentDir, { recursive: true, force: true });
13150
+ rmSync10(segmentDir, { recursive: true, force: true });
13114
13151
  }
13115
13152
  const segmentManifest = readSegmentManifest(segmentDir) ?? {
13116
13153
  version: 1,
@@ -13191,7 +13228,7 @@ async function executeRenderPipeline(input) {
13191
13228
  );
13192
13229
  updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
13193
13230
  if (job.config.keepSegments !== true) {
13194
- rmSync9(segmentDir, { recursive: true, force: true });
13231
+ rmSync10(segmentDir, { recursive: true, force: true });
13195
13232
  }
13196
13233
  } else {
13197
13234
  capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
@@ -13460,7 +13497,7 @@ async function executeRenderPipeline(input) {
13460
13497
  "capture_disk",
13461
13498
  "drawElement capture failed; retrying with a fresh screenshot session"
13462
13499
  );
13463
- rmSync9(framesDir, { recursive: true, force: true });
13500
+ rmSync10(framesDir, { recursive: true, force: true });
13464
13501
  mkdirSync9(framesDir, { recursive: true });
13465
13502
  resetCaptureAttemptProgress(job);
13466
13503
  dedupPerfs.length = 0;
@@ -13647,7 +13684,7 @@ async function executeRenderPipeline(input) {
13647
13684
  }
13648
13685
  if (job.config.debug) {
13649
13686
  if (!isPngSequence && !isHls && existsSync16(stagedOutputPath)) {
13650
- const debugOutput = join26(workDir, `output${videoExt}`);
13687
+ const debugOutput = join25(workDir, `output${videoExt}`);
13651
13688
  copyFileSync4(stagedOutputPath, debugOutput);
13652
13689
  }
13653
13690
  }
@@ -13759,7 +13796,7 @@ import {
13759
13796
  closeSync as closeSync5,
13760
13797
  constants as constants3
13761
13798
  } from "node:fs";
13762
- import { resolve as resolve8, join as join27, relative as relative3, isAbsolute as isAbsolute3, sep as sep2 } from "node:path";
13799
+ import { resolve as resolve8, join as join26, relative as relative3, isAbsolute as isAbsolute3, sep as sep2 } from "node:path";
13763
13800
  import { lintHyperframeHtml } from "@hyperframes/lint";
13764
13801
  function isStringRecord(value) {
13765
13802
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -13827,7 +13864,7 @@ function readProjectEntryFile(projectDir, preferredEntryFile) {
13827
13864
  }
13828
13865
  }
13829
13866
  return {
13830
- error: `No HTML entry file found in project directory: ${join27(absProjectDir, preferredEntryFile || "index.html")}`
13867
+ error: `No HTML entry file found in project directory: ${join26(absProjectDir, preferredEntryFile || "index.html")}`
13831
13868
  };
13832
13869
  }
13833
13870
  function prepareHyperframeLintBody(body) {
@@ -13873,7 +13910,7 @@ async function runHyperframeLint(prepared) {
13873
13910
  // src/services/healthWorker.ts
13874
13911
  import { Worker as Worker2 } from "node:worker_threads";
13875
13912
  import { fileURLToPath as fileURLToPath5 } from "node:url";
13876
- import { dirname as dirname11, join as join28 } from "node:path";
13913
+ import { dirname as dirname11, join as join27 } from "node:path";
13877
13914
  import { existsSync as existsSync18 } from "node:fs";
13878
13915
  var DEFAULT_HEALTH_PORT = 9848;
13879
13916
  async function startHealthWorker(options = {}) {
@@ -13949,7 +13986,7 @@ function defaultLogger2() {
13949
13986
  }
13950
13987
  function resolveWorkerEntry2() {
13951
13988
  const here = dirname11(fileURLToPath5(import.meta.url));
13952
- const candidates = [join28(here, "healthWorkerThread.js"), join28(here, "healthWorkerThread.ts")];
13989
+ const candidates = [join27(here, "healthWorkerThread.js"), join27(here, "healthWorkerThread.ts")];
13953
13990
  for (const candidate of candidates) {
13954
13991
  if (existsSync18(candidate)) return candidate;
13955
13992
  }
@@ -13985,7 +14022,7 @@ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
13985
14022
 
13986
14023
  // src/services/distributed/plan.ts
13987
14024
  import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
13988
- import { extname as extname4, isAbsolute as isAbsolute4, join as join29, relative as relative4, sep as sep3 } from "node:path";
14025
+ import { extname as extname4, isAbsolute as isAbsolute4, join as join28, relative as relative4, sep as sep3 } from "node:path";
13989
14026
  import {
13990
14027
  fpsToNumber as fpsToNumber10,
13991
14028
  HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
@@ -14082,9 +14119,9 @@ var PLAN_ROOT_CATEGORY = {
14082
14119
  var PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
14083
14120
  var FREEZE_OWNED_PLAN_FILES = [
14084
14121
  "plan.json",
14085
- join29("meta", "composition.json"),
14086
- join29("meta", "encoder.json"),
14087
- join29("meta", "chunks.json")
14122
+ join28("meta", "composition.json"),
14123
+ join28("meta", "encoder.json"),
14124
+ join28("meta", "chunks.json")
14088
14125
  ];
14089
14126
 
14090
14127
  // src/services/distributed/renderConfigValidation.ts
@@ -14234,6 +14271,7 @@ function assertRequestOptionScalars(options) {
14234
14271
  }
14235
14272
  assertOptionalEnum(options, "strictness", ["strict", "best-effort"]);
14236
14273
  assertOptionalEnum(options, "hdrMode", ["auto", "force-hdr", "force-sdr"]);
14274
+ assertOptionalEnum(options, "pageMotionBlur", ["off"]);
14237
14275
  if (options.videoFrameFormat !== void 0 && !isVideoFrameFormat2(options.videoFrameFormat)) {
14238
14276
  throw new Error("Render request videoFrameFormat is invalid");
14239
14277
  }
@@ -14433,6 +14471,7 @@ function buildRenderJobConfig(input, outputPath, log) {
14433
14471
  outputResolution: input.outputResolution,
14434
14472
  outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
14435
14473
  videoFrameFormat: input.videoFrameFormat,
14474
+ pageMotionBlur: input.pageMotionBlur,
14436
14475
  hdrMode: toRenderHdrMode(input.outputDynamicRange)
14437
14476
  }
14438
14477
  });
@@ -14508,8 +14547,8 @@ async function resolveInlineRenderHtml(body) {
14508
14547
  }
14509
14548
  function materializeInlineProject(html, options) {
14510
14549
  const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir3();
14511
- const tempProjectDir = mkdtempSync4(join30(tempRoot, "producer-project-"));
14512
- writeFileSync6(join30(tempProjectDir, "index.html"), html, "utf-8");
14550
+ const tempProjectDir = mkdtempSync4(join29(tempRoot, "producer-project-"));
14551
+ writeFileSync6(join29(tempProjectDir, "index.html"), html, "utf-8");
14513
14552
  return {
14514
14553
  prepared: {
14515
14554
  input: { projectDir: tempProjectDir, ...options },
@@ -14520,7 +14559,11 @@ function materializeInlineProject(html, options) {
14520
14559
  async function prepareRenderBody(body) {
14521
14560
  const overrideError = validateRenderOverrides(body);
14522
14561
  if (overrideError) return { error: overrideError };
14523
- const options = parseRenderOptions(body);
14562
+ const pageMotionBlur = body.pageMotionBlur;
14563
+ if (pageMotionBlur !== void 0 && pageMotionBlur !== "off") {
14564
+ return { error: "pageMotionBlur must be off when supplied" };
14565
+ }
14566
+ const options = { ...parseRenderOptions(body), pageMotionBlur };
14524
14567
  const project = prepareProjectDirectory(body.projectDir, options);
14525
14568
  if (project) return project;
14526
14569
  const source = await resolveInlineRenderHtml(body);
@@ -14566,7 +14609,7 @@ function createArtifactStore(ttlMs) {
14566
14609
  function cleanupTempDir(dir, log) {
14567
14610
  if (!dir) return;
14568
14611
  try {
14569
- rmSync10(dir, { recursive: true, force: true });
14612
+ rmSync11(dir, { recursive: true, force: true });
14570
14613
  } catch (error) {
14571
14614
  log.warn("Failed to cleanup temp project dir", {
14572
14615
  cleanupProjectDir: dir,