@hyperframes/producer 0.8.186 → 0.8.189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,10 @@ import {
7
7
  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);
@@ -7212,7 +7213,9 @@ async function discoverMediaFromBrowser(page) {
7212
7213
  const tagName = isImage ? "image" : htmlEl.tagName.toLowerCase() === "video" ? "video" : "audio";
7213
7214
  const id = htmlEl.getAttribute("data-hf-render-id") || htmlEl.id || (isImage ? autoImageIds.get(htmlEl) : void 0);
7214
7215
  if (!id) return;
7215
- 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 || "";
7216
7219
  const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
7217
7220
  const endRaw = htmlEl.getAttribute("data-end");
7218
7221
  const durationRaw = htmlEl.getAttribute("data-duration");
@@ -8679,775 +8682,1213 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
8679
8682
  }
8680
8683
 
8681
8684
  // src/services/render/stages/audioStage.ts
8682
- import { join as join17 } from "node:path";
8685
+ import { join as join18 } from "node:path";
8686
+ import { rmSync as rmSync8 } from "node:fs";
8683
8687
  import {
8684
8688
  MIXED_AUDIO_FILENAME,
8685
8689
  audioFailureDiagnostics,
8686
8690
  processCompositionAudio
8687
8691
  } from "@hyperframes/engine";
8688
- function triggerFirst(failures, trigger) {
8689
- 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;
8690
8722
  }
8691
- async function runAudioStage(input) {
8692
- const {
8693
- projectDir,
8694
- workDir,
8695
- compiledDir,
8696
- duration,
8697
- ffmpegProcessTimeout,
8698
- audioGain,
8699
- audios,
8700
- masterFxChain,
8701
- urlByAbsentLocalPath,
8702
- keepEncoderInputAt,
8703
- abortSignal,
8704
- assertNotAborted,
8705
- log
8706
- } = input;
8707
- const stage3Start = Date.now();
8708
- const audioOutputPath = join17(workDir, MIXED_AUDIO_FILENAME);
8709
- let hasAudio = false;
8710
- let audioError;
8711
- let audioFailures;
8712
- if (audios.length > 0) {
8713
- let audioResult;
8714
- try {
8715
- audioResult = await processCompositionAudio(
8716
- audios,
8717
- projectDir,
8718
- join17(workDir, "audio-work"),
8719
- audioOutputPath,
8720
- duration,
8721
- abortSignal,
8722
- {
8723
- ffmpegProcessTimeout,
8724
- audioGain,
8725
- masterFxChain,
8726
- urlByAbsentLocalPath,
8727
- keepEncoderInputAt
8728
- },
8729
- compiledDir
8730
- );
8731
- } catch (err) {
8732
- assertNotAborted();
8733
- const detail = err instanceof Error ? err.message : String(err);
8734
- return {
8735
- audioOutputPath,
8736
- hasAudio: false,
8737
- audioProcessMs: Date.now() - stage3Start,
8738
- audioError: detail,
8739
- // Synthesised rather than left undefined. The warning policy reads
8740
- // owner, retryability, reason and stage off this list, so reporting the
8741
- // FATAL failure — the one that took the whole mix down — with an empty
8742
- // one gave it strictly less classification than a single dropped track
8743
- // gets, which is the opposite of what this stage exists to do.
8744
- // "internal" is the honest bucket: the stages enumerate ffmpeg steps
8745
- // and this is the FX render, which is none of them.
8746
- audioFailures: [
8747
- {
8748
- stage: "internal",
8749
- reason: "internal",
8750
- owner: "system",
8751
- retryable: false,
8752
- detail: detail.slice(0, 2e3)
8753
- }
8754
- ]
8755
- };
8756
- }
8757
- assertNotAborted();
8758
- const interrupted = audioResult.failures?.find(
8759
- (failure) => failure.reason === "external_interruption"
8760
- );
8761
- if (interrupted) {
8762
- throw encoderFailureError("Audio processing failed", {
8763
- error: interrupted.detail,
8764
- failureReason: "external_interruption",
8765
- audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted))
8766
- });
8767
- }
8768
- hasAudio = audioResult.success;
8769
- audioFailures = audioResult.failures;
8770
- if (!hasAudio) {
8771
- audioError = audioResult.error ?? "audio mix failed for an unknown reason";
8772
- for (const failure of audioResult.failures ?? []) {
8773
- log?.warn("Audio track failed", {
8774
- elementId: failure.elementId,
8775
- stage: failure.stage,
8776
- reason: failure.reason,
8777
- owner: failure.owner,
8778
- retryable: failure.retryable,
8779
- detail: failure.detail
8780
- });
8781
- }
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);
8782
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)}`);
8783
8744
  }
8784
- const audioProcessMs = Date.now() - stage3Start;
8745
+ const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
8785
8746
  return {
8786
- audioOutputPath,
8787
- hasAudio,
8788
- audioProcessMs,
8789
- audioError,
8790
- audioFailures
8747
+ peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
8748
+ firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
8749
+ laterBlocks: checkedDbfs(laterBlocks)
8791
8750
  };
8792
8751
  }
8793
-
8794
- // src/services/render/stages/captureStage.ts
8795
- import { existsSync as existsSync12, readdirSync as readdirSync3, statfsSync as statfsSync2 } from "node:fs";
8796
- import { join as join18 } from "node:path";
8797
- import {
8798
- classifyCaptureFailure as classifyCaptureFailure2,
8799
- frameFileExtension,
8800
- captureFrame,
8801
- captureFrameToBufferPipelined as captureFrameToBufferPipelined2,
8802
- verifyDiskDrawElementSamples,
8803
- writeCapturedFrame,
8804
- closeCaptureSession as closeCaptureSession5,
8805
- completeDeferredDrawElementInit as completeDeferredDrawElementInit3,
8806
- createCaptureSession as createCaptureSession5,
8807
- getCapturePerfSummary as getCapturePerfSummary3,
8808
- initializeSession as initializeSession5,
8809
- prepareCaptureSessionForReuse as prepareCaptureSessionForReuse3
8810
- } from "@hyperframes/engine";
8811
- function shouldAllowAdaptiveCaptureRetry(workerCount, _explicitlyConfigured) {
8812
- 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();
8813
8781
  }
8814
- var JPEG_RAW_RGBA_DIVISOR = 8;
8815
- var HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR = 4;
8816
- var DISK_PROJECTION_SAMPLE_FRAMES = 10;
8817
- function estimateDiskCaptureBytes(totalFrames, captureOptions) {
8818
- const scale = captureOptions.deviceScaleFactor ?? 1;
8819
- const outputWidth = Math.ceil(captureOptions.width * scale);
8820
- const outputHeight = Math.ceil(captureOptions.height * scale);
8821
- const rawBytes = Math.ceil(totalFrames) * outputWidth * outputHeight * 4;
8822
- return frameFileExtension(captureOptions.format) === "jpg" ? Math.ceil(
8823
- rawBytes / ((captureOptions.quality ?? 80) > 80 ? HIGH_QUALITY_JPEG_RAW_RGBA_DIVISOR : JPEG_RAW_RGBA_DIVISOR)
8824
- ) : 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)}`;
8825
8790
  }
8826
- function freeDiskBytesAt(path) {
8827
- try {
8828
- const stat = statfsSync2(path);
8829
- return stat.bavail * stat.bsize;
8830
- } catch {
8831
- 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
+ };
8832
8800
  }
8833
- }
8834
- function inspectDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, freeDiskBytes = freeDiskBytesAt) {
8835
- const freeBytes = freeDiskBytes(framesDir);
8836
- const estimatedBytes = estimateDiskCaptureBytes(totalFrames, captureOptions);
8837
- if (freeBytes === null || estimatedBytes <= freeBytes * 0.9) {
8838
- 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
+ };
8839
8807
  }
8840
- return { available: false, estimatedBytes, freeBytes };
8808
+ return {
8809
+ operation: "trim",
8810
+ steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
8811
+ cleanupPaths: []
8812
+ };
8841
8813
  }
8842
- function diskCaptureRemedy(cfg, outputFormat, durationSeconds, workerCount) {
8843
- const oneWorker = explainStreamingEncodeGate(cfg, outputFormat, 1, durationSeconds);
8844
- if (workerCount > 1 && oneWorker.enabled) {
8845
- 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.`;
8846
- }
8847
- switch (oneWorker.reason) {
8848
- case "format_excluded":
8849
- return `${outputFormat} output stores every frame at any worker count. Free up disk space or render to mp4.`;
8850
- case "disabled_by_config":
8851
- return "PRODUCER_ENABLE_STREAMING_ENCODE=false sends every render to disk. Free up disk space or unset it.";
8852
- case "duration_cap":
8853
- 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.";
8854
- default:
8855
- return "Free up disk space.";
8856
- }
8814
+ function formatSeconds(sec) {
8815
+ return sec.toFixed(6);
8857
8816
  }
8858
- function diskCaptureShortfallError(needBytes, freeBytes, framesDir, basis, remedy) {
8859
- const basisNote = basis === "measured" ? ` (measured from the first ${DISK_PROJECTION_SAMPLE_FRAMES} frames)` : "";
8860
- return new Error(
8861
- `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}`
8862
- );
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);
8863
8821
  }
8864
- function assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy, freeDiskBytes) {
8865
- const headroom = inspectDiskCaptureHeadroom(
8866
- framesDir,
8867
- totalFrames,
8868
- captureOptions,
8869
- freeDiskBytes
8870
- );
8871
- if (headroom.available) return;
8872
- throw diskCaptureShortfallError(
8873
- headroom.estimatedBytes,
8874
- headroom.freeBytes,
8875
- framesDir,
8876
- "estimate",
8877
- 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
8878
8872
  );
8879
- }
8880
- function measureCaptureFrameBytes(workDir, framesDir) {
8881
- let total = sampleDirectoryBytes(framesDir);
8882
- if (!existsSync12(workDir)) return total;
8883
- for (const attempt of readdirSync3(workDir)) {
8884
- if (/^retry-\d+-batch-\d+-worker-\d+$/.test(attempt)) {
8885
- total += sampleDirectoryBytes(join18(workDir, attempt));
8886
- continue;
8887
- }
8888
- if (!/^capture-attempt-\d+$/.test(attempt)) continue;
8889
- const attemptDir = join18(workDir, attempt);
8890
- for (const name of readdirSync3(attemptDir)) {
8891
- 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
+ }
8892
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 });
8893
8899
  }
8894
- return total;
8895
- }
8896
- function createDiskCaptureProjection(input) {
8897
- const { framesDir, totalFrames, measureBytes, remedy, freeDiskBytes = freeDiskBytesAt } = input;
8898
- let checked = false;
8899
- return (capturedFrames) => {
8900
- if (checked || capturedFrames < DISK_PROJECTION_SAMPLE_FRAMES) return;
8901
- const measured = measureBytes();
8902
- if (measured <= 0) return;
8903
- checked = true;
8904
- const remaining = totalFrames - capturedFrames;
8905
- const freeBytes = freeDiskBytes(framesDir);
8906
- if (remaining <= 0 || freeBytes === null) return;
8907
- const needBytes = Math.ceil(measured / capturedFrames * remaining);
8908
- if (needBytes > freeBytes * 0.9) {
8909
- 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
+ };
8910
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 } : {}
8911
8929
  };
8912
8930
  }
8913
- async function runCaptureStage(input) {
8914
- const {
8915
- fileServer,
8916
- workDir,
8917
- framesDir,
8918
- job,
8919
- totalFrames,
8920
- cfg,
8921
- plan,
8922
- log,
8923
- captureAttempts,
8924
- buildCaptureOptions,
8925
- createRenderVideoFrameInjector,
8926
- abortSignal,
8927
- onProgress,
8928
- frameRange,
8929
- dedupPerfs
8930
- } = input;
8931
- let { probeSession } = input;
8932
- let { workerCount } = plan;
8933
- const { forceScreenshot } = plan;
8934
- let lastBrowserConsole = [];
8935
- let captureBeyondViewport = probeSession?.options.captureBeyondViewport;
8936
- const captureCfg = cfg.forceScreenshot === forceScreenshot ? cfg : { ...cfg, forceScreenshot };
8937
- if (frameRange !== void 0) {
8938
- if (!Number.isFinite(frameRange.startFrame) || !Number.isFinite(frameRange.endFrame) || frameRange.startFrame < 0 || frameRange.endFrame <= frameRange.startFrame) {
8939
- throw new Error(
8940
- `[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`
8941
8953
  );
8942
- }
8943
- const rangeFrames = frameRange.endFrame - frameRange.startFrame;
8944
- if (rangeFrames !== totalFrames) {
8945
- throw new Error(
8946
- `[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)
8947
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;
8948
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 });
8949
8997
  }
8950
- const captureOptions = buildCaptureOptions();
8951
- const remedy = diskCaptureRemedy(cfg, job.config.format ?? "mp4", job.duration ?? 0, workerCount);
8952
- assertDiskCaptureHeadroom(framesDir, totalFrames, captureOptions, remedy);
8953
- const checkDiskProjection = createDiskCaptureProjection({
8954
- framesDir,
8955
- totalFrames,
8956
- measureBytes: () => measureCaptureFrameBytes(workDir, framesDir),
8957
- remedy
8958
- });
8959
- if (workerCount > 1) {
8960
- const attempts = await executeDiskCaptureWithAdaptiveRetry({
8961
- serverUrl: fileServer.url,
8962
- workDir,
8963
- framesDir,
8964
- totalFrames,
8965
- initialWorkerCount: workerCount,
8966
- allowRetry: shouldAllowAdaptiveCaptureRetry(workerCount, job.config.workers !== void 0),
8967
- frameExt: frameFileExtension(captureOptions.format),
8968
- captureOptions,
8969
- createBeforeCaptureHook: createRenderVideoFrameInjector,
8970
- abortSignal,
8971
- frameRangeStart: frameRange?.startFrame,
8972
- dedupPerfs,
8973
- onProgress: (progress) => {
8974
- if (progress.latestWorkerPhase) {
8975
- if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
8976
- return;
8977
- }
8978
- if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
8979
- job.framesRendered = progress.capturedFrames;
8980
- checkDiskProjection(progress.capturedFrames);
8981
- const frameProgress = progress.capturedFrames / progress.totalFrames;
8982
- reportFrameProgress(
8983
- job,
8984
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
8985
- Math.round(25 + frameProgress * 45),
8986
- onProgress,
8987
- progress.capturedFrames,
8988
- progress.totalFrames
8989
- );
8990
- },
8991
- cfg: captureCfg,
8992
- log
8993
- });
8994
- captureAttempts.push(...attempts);
8995
- const lastAttempt = attempts[attempts.length - 1];
8996
- if (lastAttempt) {
8997
- 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.");
8998
9113
  }
8999
- if (probeSession) {
9000
- captureBeyondViewport = probeSession.options.captureBeyondViewport;
9001
- lastBrowserConsole = probeSession.browserConsoleBuffer;
9002
- await closeCaptureSession5(probeSession);
9003
- 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;
9004
9158
  }
9005
- } else {
9006
- const sequential = await runSequentialDiskCapture(
9007
- input,
9008
- captureOptions,
9009
- captureCfg,
9010
- checkDiskProjection
9011
- );
9012
- probeSession = null;
9013
- lastBrowserConsole = sequential.lastBrowserConsole;
9014
- captureBeyondViewport = sequential.captureBeyondViewport;
9159
+ return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
9160
+ } finally {
9161
+ rmSync8(pcmSourcePath, { force: true });
9015
9162
  }
9016
- return { workerCount, probeSession, lastBrowserConsole, captureBeyondViewport };
9017
9163
  }
9018
- async function runSequentialDiskCapture(input, captureOptions, captureCfg, checkDiskProjection) {
9164
+ async function mixAudioForDelivery(input, duration, pcmSourcePath) {
9019
9165
  const {
9020
- fileServer,
9021
- framesDir,
9022
- totalFrames,
9023
- log,
9024
- createRenderVideoFrameInjector,
9166
+ projectDir,
9167
+ workDir,
9168
+ compiledDir,
9169
+ ffmpegProcessTimeout,
9170
+ audioGain,
9171
+ audios,
9172
+ masterFxChain,
9173
+ urlByAbsentLocalPath,
9025
9174
  abortSignal,
9026
9175
  assertNotAborted,
9027
- frameRange
9176
+ log
9028
9177
  } = input;
9029
- let { probeSession } = input;
9030
- let lastBrowserConsole = [];
9031
- let captureBeyondViewport;
9032
- const { rangeStart, rangeEnd } = resolveCaptureRange(frameRange, totalFrames);
9033
- const rangeFrames = rangeEnd - rangeStart;
9034
- const frameExt = frameFileExtension(captureOptions.format);
9035
- let transientRetriesUsed = 0;
9036
- let resumeFrom = 0;
9037
- while (true) {
9038
- const videoInjector = createRenderVideoFrameInjector();
9039
- const session = probeSession ?? await createCaptureSession5(
9040
- fileServer.url,
9041
- framesDir,
9042
- captureOptions,
9043
- videoInjector,
9044
- captureCfg
9045
- );
9046
- 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;
9047
9184
  try {
9048
- if (probeSession) {
9049
- prepareCaptureSessionForReuse3(session, framesDir, videoInjector);
9050
- probeSession = null;
9051
- }
9052
- await ensureSessionReady(session);
9053
- assertNotAborted();
9054
- lastBrowserConsole = session.browserConsoleBuffer;
9055
- await captureSessionFrames(session, {
9056
- input,
9057
- rangeStart,
9058
- rangeEnd,
9059
- resumeFrom,
9060
- checkDiskProjection
9061
- });
9062
- break;
9063
- } catch (error) {
9064
- lastBrowserConsole = session.browserConsoleBuffer;
9065
- const resume = planTransientResume(error, {
9185
+ audioResult = await processCompositionAudio(
9186
+ audios,
9187
+ projectDir,
9188
+ join18(workDir, "audio-work"),
9189
+ audioOutputPath,
9190
+ duration,
9066
9191
  abortSignal,
9067
- rangeFrames,
9068
- framesDir,
9069
- frameExt,
9070
- retriesUsed: transientRetriesUsed
9071
- });
9072
- if (resume !== null) {
9073
- transientRetriesUsed++;
9074
- resumeFrom = resume;
9075
- log.warn(
9076
- "[Render] Transient browser failure during sequential capture; retrying once with a fresh session.",
9077
- {
9078
- resumeFrom,
9079
- rangeFrames,
9080
- transientRetriesUsed,
9081
- error: error instanceof Error ? error.message : String(error)
9082
- }
9083
- );
9084
- continue;
9085
- }
9086
- throw wrapCaptureStageError(error, lastBrowserConsole);
9087
- } finally {
9088
- lastBrowserConsole = session.browserConsoleBuffer;
9089
- 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
+ }
9090
9242
  }
9091
9243
  }
9092
- return { lastBrowserConsole, captureBeyondViewport };
9244
+ return {
9245
+ audioOutputPath,
9246
+ hasAudio,
9247
+ audioError,
9248
+ audioFailures
9249
+ };
9093
9250
  }
9094
- function resolveCaptureRange(frameRange, totalFrames) {
9095
- 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;
9096
9271
  }
9097
- function planTransientResume(error, ctx) {
9098
- const failure = classifyCaptureFailure2(error, { signal: ctx.abortSignal });
9099
- const missing = findMissingFrameRanges(ctx.rangeFrames, ctx.framesDir, ctx.frameExt);
9100
- const [firstMissing] = missing;
9101
- if (!firstMissing) return null;
9102
- 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;
9103
9283
  }
9104
- async function ensureSessionReady(session) {
9105
- if (!session.isInitialized) {
9106
- await initializeSession5(session);
9107
- return;
9108
- }
9109
- if (process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true") {
9110
- 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;
9111
9290
  }
9112
9291
  }
9113
- async function captureSessionFrames(session, { input, rangeStart, rangeEnd, resumeFrom, checkDiskProjection }) {
9114
- const { job, framesDir, dedupPerfs, assertNotAborted, onProgress } = input;
9115
- const rangeFrames = rangeEnd - rangeStart;
9116
- const reportFrame = (fileIndex) => {
9117
- job.framesRendered = fileIndex + 1;
9118
- checkDiskProjection(fileIndex + 1);
9119
- reportFrameProgress(
9120
- job,
9121
- `Capturing frame ${fileIndex + 1}/${rangeFrames}`,
9122
- Math.round(25 + (fileIndex + 1) / rangeFrames * 45),
9123
- onProgress,
9124
- fileIndex + 1,
9125
- rangeFrames
9126
- );
9127
- };
9128
- if (session.workerEncodeEnabled) {
9129
- let prev = null;
9130
- const drainPrev = async () => {
9131
- if (!prev) return;
9132
- assertNotAborted();
9133
- const buf = await prev.encodeResult;
9134
- writeCapturedFrame(session, prev.fileIndex, buf);
9135
- reportFrame(prev.fileIndex);
9136
- };
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
- const { encodeResult } = await captureFrameToBufferPipelined2(session, i, time);
9142
- await drainPrev();
9143
- prev = { fileIndex: i, encodeResult };
9144
- }
9145
- await drainPrev();
9146
- } else {
9147
- for (let i = resumeFrom; i < rangeFrames; i++) {
9148
- assertNotAborted();
9149
- const absoluteIdx = rangeStart + i;
9150
- const time = absoluteIdx * job.config.fps.den / job.config.fps.num;
9151
- await captureFrame(session, i, time);
9152
- reportFrame(i);
9153
- }
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 };
9154
9297
  }
9155
- await verifyDiskDrawElementSamples(
9156
- session,
9157
- {
9158
- workerId: 0,
9159
- startFrame: rangeStart + resumeFrom,
9160
- endFrame: rangeEnd,
9161
- outputDir: framesDir,
9162
- outputFrameOffset: rangeStart
9163
- },
9164
- false
9165
- );
9166
- dedupPerfs.push(getCapturePerfSummary3(session));
9298
+ return { available: false, estimatedBytes, freeBytes };
9167
9299
  }
9168
-
9169
- // src/services/render/stages/captureHdrStage.ts
9170
- import { existsSync as existsSync14, mkdirSync as mkdirSync7 } from "node:fs";
9171
- import { join as join22 } from "node:path";
9172
- import {
9173
- closeCaptureSession as closeCaptureSession7,
9174
- cloneCaptureWarnings as cloneCaptureWarnings2,
9175
- createCaptureSession as createCaptureSession7,
9176
- initTransparentBackground as initTransparentBackground2,
9177
- initializeSession as initializeSession7,
9178
- spawnStreamingEncoder as spawnStreamingEncoder3
9179
- } from "@hyperframes/engine";
9180
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
9181
-
9182
- // src/services/hdrImageTransferCache.ts
9183
- import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
9184
- var DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
9185
- function createHdrImageTransferCache(options = {}) {
9186
- const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
9187
- if (!Number.isInteger(maxBytes) || maxBytes < 0) {
9188
- throw new Error(
9189
- `createHdrImageTransferCache: maxBytes must be a non-negative integer, got ${String(maxBytes)}`
9190
- );
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.`;
9191
9304
  }
9192
- const entries = /* @__PURE__ */ new Map();
9193
- let totalBytes = 0;
9194
- function makeKey(imageId, targetTransfer) {
9195
- 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.";
9196
9314
  }
9197
- function evictUntilRoom(needed) {
9198
- while (totalBytes + needed > maxBytes && entries.size > 0) {
9199
- const lruKey = entries.keys().next().value;
9200
- if (lruKey === void 0) break;
9201
- const evicted = entries.get(lruKey);
9202
- if (evicted) totalBytes -= evicted.byteLength;
9203
- 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));
9204
9334
  }
9205
9335
  }
9206
- return {
9207
- getConverted(imageId, sourceTransfer, targetTransfer, source) {
9208
- if (sourceTransfer === targetTransfer) {
9209
- return source;
9210
- }
9211
- if (maxBytes === 0) {
9212
- const fresh = Buffer.from(source);
9213
- convertTransfer2(fresh, sourceTransfer, targetTransfer);
9214
- return fresh;
9215
- }
9216
- const key = makeKey(imageId, targetTransfer);
9217
- const existing = entries.get(key);
9218
- if (existing) {
9219
- entries.delete(key);
9220
- entries.set(key, existing);
9221
- return existing;
9222
- }
9223
- const converted = Buffer.from(source);
9224
- convertTransfer2(converted, sourceTransfer, targetTransfer);
9225
- if (converted.byteLength > maxBytes) {
9226
- return converted;
9227
- }
9228
- evictUntilRoom(converted.byteLength);
9229
- entries.set(key, converted);
9230
- totalBytes += converted.byteLength;
9231
- return converted;
9232
- },
9233
- size() {
9234
- return entries.size;
9235
- },
9236
- bytesUsed() {
9237
- 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);
9238
9352
  }
9239
9353
  };
9240
9354
  }
9241
-
9242
- // src/services/render/stages/captureHdrSequentialLoop.ts
9243
- import { join as join19 } from "node:path";
9244
- import {
9245
- TRANSITIONS,
9246
- crossfade
9247
- } from "@hyperframes/engine";
9248
- async function runSequentialLayeredFrameLoop(input) {
9355
+ async function runCaptureStage(input) {
9249
9356
  const {
9357
+ fileServer,
9358
+ workDir,
9359
+ framesDir,
9250
9360
  job,
9251
- log,
9252
- width,
9253
- height,
9254
9361
  totalFrames,
9255
- nativeHdrIds,
9256
- nativeHdrImageIds,
9257
- hdrCompositeCtx,
9258
- hdrPerf,
9259
- hdrEncoder,
9260
- domSession,
9261
- transitionRanges,
9262
- sceneElements,
9263
- compositeTransfer,
9264
- hdrTargetTransfer,
9265
- hdrVideoEndTimes,
9266
- cleanedUpVideos,
9267
- hdrVideoFrameSources,
9268
- debugDumpEnabled,
9269
- debugDumpDir,
9270
- assertNotAborted,
9271
- onProgress
9362
+ cfg,
9363
+ plan,
9364
+ log,
9365
+ captureAttempts,
9366
+ buildCaptureOptions,
9367
+ createRenderVideoFrameInjector,
9368
+ abortSignal,
9369
+ onProgress,
9370
+ frameRange,
9371
+ dedupPerfs
9272
9372
  } = input;
9273
- const beforeCaptureHook = domSession.onBeforeCapture;
9274
- const bufSize = width * height * 6;
9275
- const hasTransitions = transitionRanges.length > 0;
9276
- const transitionBuffers = hasTransitions ? {
9277
- bufferA: Buffer.alloc(bufSize),
9278
- bufferB: Buffer.alloc(bufSize),
9279
- output: Buffer.alloc(bufSize)
9280
- } : null;
9281
- const normalCanvas = Buffer.alloc(bufSize);
9282
- for (let i = 0; i < totalFrames; i++) {
9283
- assertNotAborted();
9284
- const time = i * job.config.fps.den / job.config.fps.num;
9285
- if (hdrPerf) hdrPerf.frames += 1;
9286
- const stackingInfo = await seekInjectAndQueryStacking(
9287
- domSession.page,
9288
- time,
9289
- beforeCaptureHook,
9290
- nativeHdrIds,
9291
- hdrPerf,
9292
- "frameSeekMs",
9293
- "frameInjectMs",
9294
- "stackingQueryMs"
9295
- );
9296
- const activeTransition = transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame);
9297
- if (i % 30 === 0 && (log.isLevelEnabled?.("debug") ?? true)) {
9298
- const hdrEl = stackingInfo.find((e) => e.isHdr);
9299
- log.debug("[Render] HDR layer composite frame", {
9300
- frame: i,
9301
- time: time.toFixed(2),
9302
- hdrElement: hdrEl ? { z: hdrEl.zIndex, visible: hdrEl.visible, width: hdrEl.width } : null,
9303
- stackingCount: stackingInfo.length,
9304
- activeTransition: activeTransition?.shader
9305
- });
9306
- }
9307
- if (activeTransition && transitionBuffers) {
9308
- if (hdrPerf) hdrPerf.transitionFrames += 1;
9309
- const transitionTimingStart = Date.now();
9310
- const progress = activeTransition.endFrame === activeTransition.startFrame ? 1 : (i - activeTransition.startFrame) / (activeTransition.endFrame - activeTransition.startFrame);
9311
- const sceneAIds = new Set(sceneElements[activeTransition.fromScene] ?? []);
9312
- const sceneBIds = new Set(sceneElements[activeTransition.toScene] ?? []);
9313
- timeHdrPhase(hdrPerf, "canvasClearMs", () => {
9314
- transitionBuffers.bufferA.fill(0);
9315
- transitionBuffers.bufferB.fill(0);
9316
- });
9317
- const sceneCaptures = [
9318
- [transitionBuffers.bufferA, sceneAIds],
9319
- [transitionBuffers.bufferB, sceneBIds]
9320
- ];
9321
- for (const [sceneBuf, sceneIds] of sceneCaptures) {
9322
- assertNotAborted();
9323
- await captureSceneIntoBuffer({
9324
- session: domSession,
9325
- sceneBuf,
9326
- sceneIds,
9327
- stackingInfo,
9328
- time,
9329
- width,
9330
- height,
9331
- nativeHdrIds,
9332
- nativeHdrImageIds,
9333
- beforeCaptureHook,
9334
- hdrCompositeCtx,
9335
- compositeTransfer,
9336
- hdrTargetTransfer,
9337
- hdrPerf,
9338
- log,
9339
- frameIdx: i
9340
- });
9341
- }
9342
- const shaderName = activeTransition.shader;
9343
- const transitionFn = shaderName ? TRANSITIONS[shaderName] ?? crossfade : crossfade;
9344
- transitionFn(
9345
- transitionBuffers.bufferA,
9346
- transitionBuffers.bufferB,
9347
- transitionBuffers.output,
9348
- width,
9349
- height,
9350
- progress
9351
- );
9352
- addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart);
9353
- await timeHdrPhaseAsync(
9354
- hdrPerf,
9355
- "encoderWriteMs",
9356
- async () => ensureFrameWritten(await hdrEncoder.writeFrame(transitionBuffers.output), i, hdrEncoder)
9357
- );
9358
- } else {
9359
- if (hdrPerf) hdrPerf.normalFrames += 1;
9360
- timeHdrPhase(hdrPerf, "canvasClearMs", () => normalCanvas.fill(0));
9361
- await timeHdrPhaseAsync(
9362
- hdrPerf,
9363
- "normalCompositeMs",
9364
- () => 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.`
9365
9383
  );
9366
- if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9367
- writeFileExclusiveSync(
9368
- join19(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9369
- normalCanvas
9370
- );
9371
- }
9372
- await timeHdrPhaseAsync(
9373
- hdrPerf,
9374
- "encoderWriteMs",
9375
- 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)}.`
9376
9389
  );
9377
9390
  }
9378
- cleanupEndedHdrVideos({
9379
- time,
9380
- activeTransition,
9381
- hdrVideoEndTimes,
9382
- cleanedUpVideos,
9383
- hdrVideoFrameSources,
9384
- 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,
9385
9433
  log
9386
9434
  });
9387
- job.framesRendered = i + 1;
9388
- reportFrameProgress(
9389
- job,
9390
- `Layered composite frame ${i + 1}/${job.totalFrames}`,
9391
- Math.round(25 + (i + 1) / totalFrames * 55),
9392
- onProgress,
9393
- i + 1,
9394
- 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
9395
9452
  );
9453
+ probeSession = null;
9454
+ lastBrowserConsole = sequential.lastBrowserConsole;
9455
+ captureBeyondViewport = sequential.captureBeyondViewport;
9396
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
+ }
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));
9397
9608
  }
9398
9609
 
9399
- // src/services/render/stages/captureHdrHybridLoop.ts
9400
- 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";
9401
9613
  import {
9402
- TRANSITIONS as TRANSITIONS2,
9403
- closeCaptureSession as closeCaptureSession6,
9404
- createCaptureSession as createCaptureSession6,
9405
- createFrameReorderBuffer as createFrameReorderBuffer2,
9406
- crossfade as crossfade2,
9407
- initTransparentBackground,
9408
- 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
9409
9620
  } from "@hyperframes/engine";
9621
+ import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
9410
9622
 
9411
- // src/services/shaderTransitionWorkerPool.ts
9412
- import { Worker } from "node:worker_threads";
9413
- import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
9414
- import { dirname as dirname7, join as join20 } from "node:path";
9415
- import { createRequire } from "node:module";
9416
- import { existsSync as existsSync13 } from "node:fs";
9417
- import { cpus as cpus2 } from "node:os";
9418
- function resolveWorkerEntry(explicit) {
9419
- if (explicit && explicit.length > 0) {
9420
- 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
+ );
9421
9632
  }
9422
- const override = process.env.HF_SHADER_WORKER_ENTRY;
9423
- if (override && override.length > 0) {
9424
- const isTs = override.endsWith(".ts");
9425
- return { path: override, isTs };
9633
+ const entries = /* @__PURE__ */ new Map();
9634
+ let totalBytes = 0;
9635
+ function makeKey(imageId, targetTransfer) {
9636
+ return `${imageId}|${targetTransfer}`;
9426
9637
  }
9427
- const moduleDir = dirname7(fileURLToPath3(import.meta.url));
9428
- const jsPath = join20(moduleDir, "shaderTransitionWorker.js");
9429
- if (existsSync13(jsPath)) return { path: jsPath, isTs: false };
9430
- const tsPath = join20(moduleDir, "shaderTransitionWorker.ts");
9431
- return { path: tsPath, isTs: true };
9432
- }
9433
- function buildExecArgv(entryIsTs) {
9434
- const inherited = [...process.execArgv];
9435
- if (!entryIsTs) return inherited;
9436
- const hasLoader = inherited.some(
9437
- (a) => a.includes("tsx/esm") || a.includes("ts-node/esm") || a.includes("--import")
9438
- );
9439
- if (hasLoader) return inherited;
9440
- try {
9441
- const require2 = createRequire(import.meta.url);
9442
- const tsxEsm = require2.resolve("tsx/esm");
9443
- inherited.push("--import", pathToFileURL(tsxEsm).href);
9444
- } 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
+ }
9445
9646
  }
9446
- 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
+ };
9447
9681
  }
9448
- async function createShaderTransitionWorkerPool(opts) {
9449
- const cpuCount = Math.max(1, cpus2().length);
9450
- const size = Math.max(1, Math.min(opts.size, cpuCount));
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;
9888
+ }
9889
+ async function createShaderTransitionWorkerPool(opts) {
9890
+ const cpuCount = Math.max(1, cpus2().length);
9891
+ const size = Math.max(1, Math.min(opts.size, cpuCount));
9451
9892
  const log = opts.log ?? {};
9452
9893
  const { path: entry, isTs: entryIsTs } = resolveWorkerEntry(opts.workerEntryPath);
9453
9894
  const slots = [];
@@ -9795,7 +10236,7 @@ async function runHybridLayeredFrameLoop(input) {
9795
10236
  );
9796
10237
  if (debugDumpEnabled && debugDumpDir && i % 30 === 0) {
9797
10238
  writeFileExclusiveSync(
9798
- join21(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
10239
+ join22(debugDumpDir, `frame_${String(i).padStart(4, "0")}_final_rgb48le.bin`),
9799
10240
  canvas
9800
10241
  );
9801
10242
  }
@@ -9981,7 +10422,7 @@ async function runCaptureHdrStage(input) {
9981
10422
  if (hdrVideoFrameSources.has(v.id)) hdrVideoEndTimes.set(v.id, v.end);
9982
10423
  }
9983
10424
  const debugDumpEnabled = process.env.KEEP_TEMP === "1";
9984
- const debugDumpDir = debugDumpEnabled ? join22(framesDir, "debug-composite") : null;
10425
+ const debugDumpDir = debugDumpEnabled ? join23(framesDir, "debug-composite") : null;
9985
10426
  if (debugDumpDir && !existsSync14(debugDumpDir)) {
9986
10427
  mkdirSync7(debugDumpDir, { recursive: true });
9987
10428
  }
@@ -10081,862 +10522,435 @@ async function runCaptureHdrStage(input) {
10081
10522
  onProgress
10082
10523
  });
10083
10524
  }
10084
- } finally {
10085
- lastBrowserConsole = domSession.browserConsoleBuffer;
10086
- await closeCaptureSession7(domSession);
10087
- domSessionClosed = true;
10088
- }
10089
- const encodeFrom = job.progress;
10090
- const hdrEncodeResult = await hdrEncoder.close(
10091
- (frames) => reportEncodeProgress(job, frames, totalFrames, onProgress, encodeFrom)
10092
- );
10093
- hdrEncoderClosed = true;
10094
- assertNotAborted();
10095
- if (!hdrEncodeResult.success) {
10096
- throw encoderFailureError("HDR encode failed", hdrEncodeResult);
10097
- }
10098
- reportEncodeProgress(job, totalFrames, totalFrames, onProgress, encodeFrom);
10099
- captureDurationMs = Date.now() - stageStart;
10100
- encodeMs = hdrEncodeResult.durationMs;
10101
- } catch (error) {
10102
- lastBrowserConsole = domSession.browserConsoleBuffer;
10103
- throw wrapCaptureStageError(error, lastBrowserConsole);
10104
- } finally {
10105
- if (hdrEncoder && !hdrEncoderClosed) {
10106
- try {
10107
- await hdrEncoder.close();
10108
- } catch (err) {
10109
- log.warn("hdrEncoder defensive close failed", {
10110
- err: err instanceof Error ? err.message : String(err)
10111
- });
10112
- }
10113
- }
10114
- if (!domSessionClosed) {
10115
- await closeCaptureSession7(domSession).catch((err) => {
10116
- log.warn("closeCaptureSession defensive close failed", {
10117
- err: err instanceof Error ? err.message : String(err)
10118
- });
10119
- });
10120
- }
10121
- for (const frameSource of hdrVideoFrameSources.values()) {
10122
- cleanupHdrVideoFrameSource(frameSource, log);
10123
- }
10124
- hdrVideoFrameSources.clear();
10125
- releaseHdrExtractionReservation?.();
10126
- releaseHdrExtractionReservation = null;
10127
- }
10128
- return {
10129
- lastBrowserConsole,
10130
- hdrPerf,
10131
- captureDurationMs,
10132
- encodeMs,
10133
- warnings: cloneCaptureWarnings2(domSession.warnings)
10134
- };
10135
- }
10136
-
10137
- // src/services/render/stages/encodeStage.ts
10138
- import {
10139
- copyFileSync as copyFileSync3,
10140
- existsSync as existsSync15,
10141
- mkdirSync as mkdirSync8,
10142
- readdirSync as readdirSync4,
10143
- readFileSync as readFileSync7,
10144
- rmSync as rmSync7,
10145
- statSync as statSync5,
10146
- writeFileSync as writeFileSync4
10147
- } from "node:fs";
10148
- import { readFile as readFile2 } from "node:fs/promises";
10149
- import { dirname as dirname8, join as join24 } from "node:path";
10150
- import {
10151
- encodeFramesChunkedConcat,
10152
- encodeFramesFromDir,
10153
- formatFfmpegError,
10154
- MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10155
- resolveConfig,
10156
- runFfmpeg as runFfmpeg3,
10157
- spawnStreamingEncoder as spawnStreamingEncoder4
10158
- } from "@hyperframes/engine";
10159
- import {
10160
- clearGifFramesBeforeNext,
10161
- gifClearsAfterLeavingFrameInPlace
10162
- } from "@hyperframes/core";
10163
-
10164
- // src/services/render/stages/gifEncodeArgs.ts
10165
- import { join as join23 } from "node:path";
10166
- function fpsToFfmpegArg2(fps) {
10167
- return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
10168
- }
10169
- var KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
10170
- var WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
10171
- var FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE = ["-xerror"];
10172
- function framesInput(input, fpsArg) {
10173
- return [
10174
- ...FAIL_ON_UNREADABLE_FRAME_INSTEAD_OF_ENDING_THERE,
10175
- "-framerate",
10176
- fpsArg,
10177
- ...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
10178
- "-i",
10179
- join23(input.framesDir, input.framePattern)
10180
- ];
10181
- }
10182
- function buildGifPalettegenArgs(input) {
10183
- const fpsArg = fpsToFfmpegArg2(input.fps);
10184
- const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
10185
- return [
10186
- "-y",
10187
- ...framesInput(input, fpsArg),
10188
- "-vf",
10189
- `fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
10190
- input.palettePath
10191
- ];
10192
- }
10193
- function buildGifPaletteuseArgs(input) {
10194
- const fpsArg = fpsToFfmpegArg2(input.fps);
10195
- const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
10196
- return [
10197
- "-y",
10198
- ...framesInput(input, fpsArg),
10199
- "-i",
10200
- input.palettePath,
10201
- "-lavfi",
10202
- `fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
10203
- "-loop",
10204
- String(input.loop),
10205
- ...input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : [],
10206
- input.outputPath
10207
- ];
10208
- }
10209
-
10210
- // src/services/render/stages/encodeStage.ts
10211
- import { frameFileExtension as frameFileExtension2 } from "@hyperframes/engine";
10212
- function resolveGifLoop(loop) {
10213
- const resolved = loop ?? 0;
10214
- if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) {
10215
- throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
10216
- }
10217
- return resolved;
10218
- }
10219
- function gifEncodeFailure(startTime, outputPath, error, failureReason) {
10220
- return {
10221
- success: false,
10222
- outputPath,
10223
- durationMs: Date.now() - startTime,
10224
- framesEncoded: 0,
10225
- fileSize: 0,
10226
- error,
10227
- failureReason
10228
- };
10229
- }
10230
- async function reencodeWithClearedWholeFrames(argsInput, run, startTime) {
10231
- const { outputPath } = argsInput;
10232
- const result = await runFfmpeg3(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
10233
- if (!result.success) {
10234
- const error = formatFfmpegError(result.exitCode, result.stderr);
10235
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10236
- }
10237
- const gif = readFileSync7(outputPath);
10238
- if (!clearGifFramesBeforeNext(gif)) {
10239
- const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
10240
- return gifEncodeFailure(startTime, outputPath, error);
10241
- }
10242
- writeFileSync4(outputPath, gif);
10243
- return null;
10244
- }
10245
- async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
10246
- const startTime = Date.now();
10247
- const files = readdirSync4(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
10248
- const frameCount = files.length;
10249
- if (frameCount === 0) {
10250
- return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
10251
- }
10252
- const argsInput = {
10253
- framesDir,
10254
- framePattern,
10255
- palettePath: input.palettePath,
10256
- outputPath,
10257
- fps: input.fps,
10258
- loop: input.loop,
10259
- preserveAlpha: input.preserveAlpha
10260
- };
10261
- try {
10262
- const run = { signal: input.signal, timeout: input.timeout };
10263
- for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
10264
- const result = await runFfmpeg3(args, run);
10265
- if (!result.success) {
10266
- const error = formatFfmpegError(result.exitCode, result.stderr);
10267
- return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10268
- }
10269
- }
10270
- if (input.preserveAlpha && gifClearsAfterLeavingFrameInPlace(readFileSync7(outputPath)) !== false) {
10271
- const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
10272
- if (failure) return failure;
10273
- }
10274
- const fileSize = existsSync15(outputPath) ? statSync5(outputPath).size : 0;
10275
- return {
10276
- success: true,
10277
- outputPath,
10278
- durationMs: Date.now() - startTime,
10279
- framesEncoded: frameCount,
10280
- fileSize
10281
- };
10282
- } finally {
10283
- rmSync7(input.palettePath, { force: true });
10284
- }
10285
- }
10286
- function startEncodeProgress(job, stage, onProgress) {
10287
- const total = job.totalFrames ?? 0;
10288
- updateJobStatus(job, "encoding", stage, 75, onProgress, {
10289
- code: "encode",
10290
- ...total > 0 && { done: 0, total }
10291
- });
10292
- return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10293
- }
10294
- function resolveEngineConfig(input) {
10295
- return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10296
- }
10297
- function videoEncoderOptions(input, engineCfg) {
10298
- const { job, preset } = input;
10299
- return {
10300
- fps: job.config.fps,
10301
- width: input.width,
10302
- height: input.height,
10303
- codec: preset.codec,
10304
- preset: preset.preset,
10305
- quality: input.effectiveQuality,
10306
- bitrate: input.effectiveBitrate,
10307
- pixelFormat: preset.pixelFormat,
10308
- vp9CpuUsed: engineCfg.vp9CpuUsed,
10309
- useGpu: job.config.useGpu,
10310
- hdr: preset.hdr,
10311
- // Distributed chunk renders pass these so the encoder writes closed-GOP
10312
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
10313
- // renders leave both undefined → preserves the existing open-GOP output.
10314
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10315
- gopSize: input.gopSize
10316
- };
10317
- }
10318
- function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10319
- 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;
10320
- }
10321
- async function readLandedFrame(workerPath, mergedPath) {
10322
- if (workerPath !== void 0) {
10323
- try {
10324
- return await readFile2(workerPath);
10325
- } catch (error) {
10326
- if (error.code !== "ENOENT") throw error;
10327
- }
10328
- }
10329
- return readFile2(mergedPath);
10330
- }
10331
- function encodeWhileCapturing(input, totalFrames) {
10332
- const engineCfg = resolveEngineConfig(input);
10333
- const stop = new AbortController();
10334
- const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10335
- const landed = /* @__PURE__ */ new Map();
10336
- let captureDone = false;
10337
- let framesFed = 0;
10338
- let wake = null;
10339
- const nudge = () => {
10340
- wake?.();
10341
- wake = null;
10342
- };
10343
- signal.addEventListener("abort", nudge, { once: true });
10344
- let reportFed = null;
10345
- const encoder = spawnStreamingEncoder4(
10346
- input.videoOnlyPath,
10347
- { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10348
- signal,
10349
- engineCfg
10350
- );
10351
- const fed = (async () => {
10352
- const stream = await encoder;
10353
- for (let next = 0; next < totalFrames; next++) {
10354
- while (!landed.has(next) && !captureDone && !signal.aborted) {
10355
- await new Promise((resolve10) => wake = resolve10);
10356
- }
10357
- if (signal.aborted) return;
10358
- const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10359
- const frame = await readLandedFrame(landed.get(next), mergedPath);
10360
- landed.delete(next);
10361
- if (!await stream.writeFrame(frame)) {
10362
- throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10363
- }
10364
- framesFed++;
10365
- reportFed?.(framesFed);
10366
- }
10367
- })().then(
10368
- () => void 0,
10369
- (error) => error ?? new Error("encode failed")
10370
- );
10371
- const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10372
- return {
10373
- onFrameLanded: (frame) => {
10374
- landed.set(frame.index, frame.path);
10375
- nudge();
10376
- },
10377
- finish: async () => {
10378
- const start = Date.now();
10379
- captureDone = true;
10380
- input.log.info("[Render] Encoded frames while capturing", {
10381
- framesFed,
10382
- totalFrames
10383
- });
10384
- const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10385
- reportFed = encoded;
10386
- nudge();
10387
- const failure = await fed;
10388
- const result = await closeEncoder();
10389
- input.assertNotAborted();
10390
- if (failure === void 0 && result?.success) {
10391
- encoded();
10392
- return { encodeMs: Date.now() - start };
10393
- }
10394
- input.log.warn(
10395
- "[Render] Encoding while capturing failed; encoding the frames after capture",
10396
- {
10397
- error: String(failure ?? result?.error)
10398
- }
10399
- );
10400
- return runEncodeStage(input);
10401
- },
10402
- abort: async () => {
10403
- stop.abort();
10404
- await fed;
10405
- await closeEncoder();
10406
- },
10407
- get framesFed() {
10408
- return framesFed;
10409
- }
10410
- };
10411
- }
10412
- async function runEncodeStage(input) {
10413
- const {
10414
- job,
10415
- log,
10416
- outputPath,
10417
- framesDir,
10418
- videoOnlyPath,
10419
- needsAlpha,
10420
- hasAudio,
10421
- audioOutputPath,
10422
- isPngSequence,
10423
- isGif,
10424
- enableChunkedEncode,
10425
- chunkedEncodeSize,
10426
- abortSignal,
10427
- assertNotAborted,
10428
- onProgress
10429
- } = input;
10430
- const stage5Start = Date.now();
10431
- if (isPngSequence) {
10432
- const encoded = startEncodeProgress(job, "Writing PNG sequence", onProgress);
10433
- if (!existsSync15(outputPath)) mkdirSync8(outputPath, { recursive: true });
10434
- const captured = readdirSync4(framesDir).filter((name) => name.endsWith(".png")).sort();
10435
- if (captured.length === 0) {
10436
- throw new Error(
10437
- `[Render] png-sequence output requested but no PNGs were captured to ${framesDir}`
10438
- );
10439
- }
10440
- captured.forEach((name, i) => {
10441
- const dst = join24(outputPath, formatExportFrameName(i, "png"));
10442
- copyFileSync3(join24(framesDir, name), dst);
10443
- });
10444
- if (hasAudio && audioOutputPath && existsSync15(audioOutputPath)) {
10445
- copyFileSync3(audioOutputPath, join24(outputPath, MIXED_AUDIO_FILENAME2));
10446
- log.info(
10447
- `[Render] png-sequence: ${MIXED_AUDIO_FILENAME2} sidecar written to ${outputPath}/${MIXED_AUDIO_FILENAME2}`
10448
- );
10449
- }
10450
- encoded();
10451
- return { encodeMs: Date.now() - stage5Start };
10452
- }
10453
- const engineCfg = resolveEngineConfig(input);
10454
- if (isGif) {
10455
- const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10456
- if (hasAudio) {
10457
- log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
10458
- }
10459
- const frameExt2 = frameFileExtension2(input.captureImageFormat);
10460
- const framePattern2 = `frame_%06d.${frameExt2}`;
10461
- const loop = resolveGifLoop(job.config.gifLoop);
10462
- const encodeResult2 = await encodeGifFromDir(framesDir, framePattern2, outputPath, {
10463
- fps: job.config.fps,
10464
- loop,
10465
- palettePath: join24(dirname8(videoOnlyPath), "gif-palette.png"),
10466
- preserveAlpha: needsAlpha,
10467
- signal: abortSignal,
10468
- timeout: engineCfg.ffmpegEncodeTimeout
10469
- });
10470
- assertNotAborted();
10471
- if (!encodeResult2.success) {
10472
- throw encoderFailureError("Encoding failed", encodeResult2);
10473
- }
10474
- encoded();
10475
- return { encodeMs: Date.now() - stage5Start };
10476
- }
10477
- const onFramesEncoded = startEncodeProgress(job, "Encoding video", onProgress);
10478
- const baseScaledEncodeTimeout = Math.ceil((job.duration ?? 0) * 4e3);
10479
- const scaledEncodeTimeout = baseScaledEncodeTimeout * (job.config.quality === "high" ? 6 : 1);
10480
- const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10481
- const frameExt = frameFileExtension2(input.captureImageFormat);
10482
- const framePattern = `frame_%06d.${frameExt}`;
10483
- const encoderOpts = videoEncoderOptions(input, engineCfg);
10484
- const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10485
- framesDir,
10486
- framePattern,
10487
- videoOnlyPath,
10488
- encoderOpts,
10489
- chunkedEncodeSize,
10490
- abortSignal,
10491
- videoEngineCfg,
10492
- onFramesEncoded
10493
- ) : await encodeFramesFromDir(
10494
- framesDir,
10495
- framePattern,
10496
- videoOnlyPath,
10497
- encoderOpts,
10498
- abortSignal,
10499
- videoEngineCfg,
10500
- onFramesEncoded
10501
- );
10502
- assertNotAborted();
10503
- if (!encodeResult.success) {
10504
- throw encoderFailureError("Encoding failed", encodeResult);
10505
- }
10506
- onFramesEncoded();
10507
- return { encodeMs: Date.now() - stage5Start };
10508
- }
10509
-
10510
- // src/services/render/stages/assembleStage.ts
10511
- import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
10512
- import { extname as extname3 } from "node:path";
10513
-
10514
- // src/services/render/audioPadTrim.ts
10515
- import { spawn } from "node:child_process";
10516
- import { mkdtempSync as mkdtempSync3, renameSync as renameSync4, rmSync as rmSync8 } from "node:fs";
10517
- import { dirname as dirname9, join as join25 } from "node:path";
10518
- import {
10519
- DEFAULT_CONFIG,
10520
- describeFfmpegFailure as describeFfmpegFailure2,
10521
- encodeAac,
10522
- extractAudioMetadata as extractAudioMetadata3,
10523
- getFfprobeBinary,
10524
- ManagedChildProcess,
10525
- runFfmpeg as runFfmpeg5,
10526
- trackChildProcess
10527
- } from "@hyperframes/engine";
10528
- import { redactKnownPaths, redactTelemetryString as redactTelemetryString3 } from "@hyperframes/core";
10529
-
10530
- // src/services/render/audioTruePeak.ts
10531
- import {
10532
- aacSegmentCount,
10533
- describeFfmpegFailure,
10534
- extractAudioMetadata as extractAudioMetadata2,
10535
- runFfmpeg as runFfmpeg4
10536
- } from "@hyperframes/engine";
10537
- var toDbfs = (token) => token.toLowerCase() === "-inf" ? Number.NEGATIVE_INFINITY : Number(token);
10538
- function checkedDbfs(value) {
10539
- if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY) {
10540
- throw new Error("[audioTruePeak] FFmpeg did not report AAC true peak");
10541
- }
10542
- return value;
10543
- }
10544
- async function scanTruePeak(audioPath, trim, timeoutMs, signal) {
10545
- let firstBlock;
10546
- let laterBlocks = Number.NEGATIVE_INFINITY;
10547
- let partialLine = "";
10548
- const readLines = (chunk) => {
10549
- const lines = (partialLine + chunk).split(/\r|\n/);
10550
- partialLine = lines.pop() ?? "";
10551
- for (const line of lines) {
10552
- const ftpk = /FTPK:([^d]*)dBFS/.exec(line)?.[1];
10553
- if (!ftpk) continue;
10554
- const block = Math.max(...ftpk.trim().split(/\s+/).map(toDbfs));
10555
- if (firstBlock === void 0) firstBlock = block;
10556
- else laterBlocks = Math.max(laterBlocks, block);
10557
- }
10558
- };
10559
- const result = await runFfmpeg4(
10560
- ["-i", audioPath, "-map", "0:a:0", "-vn", "-af", `${trim}ebur128=peak=true`, "-f", "null", "-"],
10561
- { signal, timeout: timeoutMs, onStderr: readLines }
10562
- );
10563
- if (!result.success) {
10564
- throw new Error(`[audioTruePeak] ${describeFfmpegFailure(result, timeoutMs)}`);
10565
- }
10566
- const summary = [...result.stderr.matchAll(/^\s*Peak:\s*([+-]?(?:[\d.]+|inf)) dBFS$/gim)];
10567
- return {
10568
- peak: checkedDbfs(toDbfs(summary.at(-1)?.[1] ?? "nan")),
10569
- firstBlock: checkedDbfs(firstBlock ?? Number.NEGATIVE_INFINITY),
10570
- laterBlocks: checkedDbfs(laterBlocks)
10571
- };
10572
- }
10573
- async function measureTruePeakDbfs(audioPath, options) {
10574
- const { timeoutMs, signal } = options;
10575
- const wholeFile = async () => (await scanTruePeak(audioPath, "", timeoutMs, signal)).peak;
10576
- const { durationSeconds, sampleRate } = await extractAudioMetadata2(audioPath, { signal });
10577
- const block = 4800;
10578
- const count = sampleRate === 48e3 && Number.isFinite(durationSeconds) ? aacSegmentCount(durationSeconds) : 1;
10579
- if (count === 1) return wholeFile();
10580
- const cut = (k) => k * Math.floor(durationSeconds * 10 / count) * block;
10581
- const peers = new AbortController();
10582
- const segmentSignal = signal ? AbortSignal.any([signal, peers.signal]) : peers.signal;
10583
- const scans = await Promise.all(
10584
- Array.from({ length: count }, (_, k) => {
10585
- const start = k === 0 ? 0 : cut(k) - block;
10586
- const end = k === count - 1 ? "" : `:end_sample=${cut(k + 1)}`;
10587
- return scanTruePeak(
10588
- audioPath,
10589
- `atrim=start_sample=${start}${end},`,
10590
- timeoutMs,
10591
- segmentSignal
10592
- );
10593
- })
10594
- ).catch((error) => {
10595
- peers.abort();
10596
- throw error;
10597
- });
10598
- const last = scans[count - 1];
10599
- const counted = Math.max(scans[0].peak, ...scans.slice(1).map((scan) => scan.laterBlocks));
10600
- if (last.peak > last.firstBlock || last.peak <= counted) return Math.max(counted, last.peak);
10601
- return wholeFile();
10602
- }
10603
-
10604
- // src/services/render/audioPadTrim.ts
10605
- var AUDIO_DURATION_TOLERANCE_SECONDS = 1e-3;
10606
- var AAC_DELIVERY_TRUE_PEAK_DBFS = -1;
10607
- var AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB = 0.5;
10608
- var MAX_TRUE_PEAK_CORRECTION_PASSES = 3;
10609
- function aacTruePeakCorrectionFilter(targetDurationSeconds, attenuationDb) {
10610
- return `volume=${attenuationDb.toFixed(3)}dB,apad,atrim=0:${formatSeconds(targetDurationSeconds)}`;
10611
- }
10612
- function buildPadTrimAudioPlan(audioPath, outputPath, sourceDurationSeconds, targetDurationSeconds) {
10613
- const delta = targetDurationSeconds - sourceDurationSeconds;
10614
- const targetSec = formatSeconds(targetDurationSeconds);
10615
- if (Math.abs(delta) < AUDIO_DURATION_TOLERANCE_SECONDS) {
10616
- return {
10617
- operation: "copy",
10618
- steps: [{ kind: "copy", args: ["-i", audioPath, "-c:a", "copy", "-y", outputPath] }],
10619
- cleanupPaths: []
10620
- };
10621
- }
10622
- if (delta > 0) {
10623
- return {
10624
- operation: "pad",
10625
- steps: [{ kind: "normalize", filter: `apad,atrim=0:${targetSec}` }],
10626
- cleanupPaths: []
10627
- };
10628
- }
10629
- return {
10630
- operation: "trim",
10631
- steps: [{ kind: "trim", filter: `atrim=duration=${targetSec},asetpts=PTS-STARTPTS` }],
10632
- cleanupPaths: []
10633
- };
10634
- }
10635
- function formatSeconds(sec) {
10636
- return sec.toFixed(6);
10637
- }
10638
- function sanitizeProbeFailure(reason, paths) {
10639
- const message = reason instanceof Error ? reason.message : String(reason);
10640
- const keepFfmpegsLastLineUncapped = Number.POSITIVE_INFINITY;
10641
- return redactTelemetryString3(redactKnownPaths(message, paths), keepFfmpegsLastLineUncapped);
10642
- }
10643
- async function padOrTrimAudioToVideoFrameCount(input) {
10644
- const probeVideo = input.probeVideoFrameInfo ?? ((videoPath) => defaultProbeVideoFrameInfo(videoPath, input.signal));
10645
- const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
10646
- const timeoutMs = input.timeoutMs ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
10647
- const runner = input.runFfmpeg ?? ((args) => defaultRunFfmpeg(args, timeoutMs, input.signal));
10648
- const encode = input.encodeAac ?? defaultAacEncoder(timeoutMs, input.signal);
10649
- const probeTruePeak = input.probeAudioTruePeakDbfs ?? (input.runFfmpeg || input.encodeAac ? void 0 : (audioPath, signal) => measureTruePeakDbfs(audioPath, { timeoutMs, signal }));
10650
- const [videoResult, audioResult] = await Promise.allSettled([
10651
- probeVideo(input.videoPath),
10652
- probeAudio(input.audioPath, input.signal)
10653
- ]);
10654
- const probePaths = [input.videoPath, input.audioPath, input.outputPath];
10655
- if (videoResult.status === "rejected") {
10656
- return failResult(
10657
- input.outputPath,
10658
- 0,
10659
- audioResult.status === "fulfilled" ? audioResult.value.durationSeconds : 0,
10660
- `audioPadTrim: failed to probe video: ${sanitizeProbeFailure(
10661
- videoResult.reason,
10662
- probePaths
10663
- )}`
10664
- );
10665
- }
10666
- if (audioResult.status === "rejected") {
10667
- return failResult(
10668
- input.outputPath,
10669
- 0,
10670
- 0,
10671
- `audioPadTrim: failed to probe audio: ${sanitizeProbeFailure(
10672
- audioResult.reason,
10673
- probePaths
10674
- )}`
10675
- );
10676
- }
10677
- const videoInfo = videoResult.value;
10678
- const audioInfo = audioResult.value;
10679
- if (!Number.isFinite(videoInfo.frameCount) || videoInfo.frameCount <= 0 || !Number.isFinite(videoInfo.fpsNum) || videoInfo.fpsNum <= 0 || !Number.isFinite(videoInfo.fpsDen) || videoInfo.fpsDen <= 0) {
10680
- return failResult(
10681
- input.outputPath,
10682
- 0,
10683
- audioInfo.durationSeconds,
10684
- `audioPadTrim: invalid video frame info: ${JSON.stringify(videoInfo)}`
10685
- );
10686
- }
10687
- const targetDurationSeconds = videoInfo.frameCount * videoInfo.fpsDen / videoInfo.fpsNum;
10688
- const plan = buildPadTrimAudioPlan(
10689
- input.audioPath,
10690
- input.outputPath,
10691
- audioInfo.durationSeconds,
10692
- targetDurationSeconds
10693
- );
10694
- try {
10695
- for (const step of plan.steps) {
10696
- const ffmpegResult = step.kind === "copy" ? await runner(step.args) : await encode(input.audioPath, input.outputPath, targetDurationSeconds, step.filter);
10697
- if (!ffmpegResult.success) {
10698
- return {
10699
- success: false,
10700
- outputPath: input.outputPath,
10701
- targetDurationSeconds,
10702
- sourceDurationSeconds: audioInfo.durationSeconds,
10703
- operation: plan.operation,
10704
- error: ffmpegResult.error,
10705
- failureReason: ffmpegResult.failureReason
10706
- };
10707
- }
10708
- }
10709
- } catch (err) {
10710
- return {
10711
- success: false,
10712
- outputPath: input.outputPath,
10713
- targetDurationSeconds,
10714
- sourceDurationSeconds: audioInfo.durationSeconds,
10715
- operation: plan.operation,
10716
- error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
10717
- };
10718
- } finally {
10719
- for (const path of plan.cleanupPaths) rmSync8(path, { force: true });
10720
- }
10721
- let loweredDb = 0;
10722
- if (probeTruePeak) {
10723
- const correction = await enforceAacTruePeak({
10724
- audioPath: input.outputPath,
10725
- targetDurationSeconds,
10726
- signal: input.signal,
10727
- probeTruePeak,
10728
- encode
10729
- });
10730
- if (!correction.success) {
10731
- return {
10732
- success: false,
10733
- outputPath: input.outputPath,
10734
- targetDurationSeconds,
10735
- sourceDurationSeconds: audioInfo.durationSeconds,
10736
- operation: plan.operation,
10737
- error: correction.error,
10738
- failureReason: correction.failureReason
10739
- };
10740
- }
10741
- loweredDb = correction.loweredDb ?? 0;
10742
- }
10743
- return {
10744
- success: true,
10745
- outputPath: input.outputPath,
10746
- targetDurationSeconds,
10747
- sourceDurationSeconds: audioInfo.durationSeconds,
10748
- operation: plan.operation,
10749
- ...loweredDb > 0 ? { audioLoweredDb: loweredDb } : {}
10750
- };
10751
- }
10752
- async function enforceAacTruePeak(input) {
10753
- let scratchDir;
10754
- try {
10755
- scratchDir = mkdtempSync3(join25(dirname9(input.audioPath), ".true-peak-"));
10756
- const correctedPath = join25(scratchDir, "audio.m4a");
10757
- const measurements = [];
10758
- let attenuationDb = 0;
10759
- let measuredPath = input.audioPath;
10760
- for (let pass = 0; pass <= MAX_TRUE_PEAK_CORRECTION_PASSES; pass += 1) {
10761
- const truePeakDbfs = await input.probeTruePeak(measuredPath, input.signal);
10762
- measurements.push(
10763
- `pass ${pass}: ${truePeakDbfs.toFixed(3)} dBFS at ${attenuationDb.toFixed(3)} dB attenuation`
10764
- );
10765
- if (Number.isNaN(truePeakDbfs) || truePeakDbfs === Number.POSITIVE_INFINITY) {
10766
- return { success: false, error: "audioPadTrim: FFmpeg reported an invalid true peak" };
10767
- }
10768
- if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
10769
- if (measuredPath !== correctedPath) return { success: true };
10770
- renameSync4(correctedPath, input.audioPath);
10771
- return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
10772
- }
10773
- if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
10774
- const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
10775
- attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
10776
- const result = await input.encode(
10777
- input.audioPath,
10778
- correctedPath,
10779
- input.targetDurationSeconds,
10780
- aacTruePeakCorrectionFilter(input.targetDurationSeconds, attenuationDb)
10781
- );
10782
- if (!result.success) {
10783
- return {
10784
- success: false,
10785
- error: sanitizeProbeFailure(
10786
- result.error ?? "audioPadTrim: failed to constrain AAC true peak",
10787
- [input.audioPath, correctedPath]
10788
- ),
10789
- failureReason: result.failureReason
10790
- };
10791
- }
10792
- measuredPath = correctedPath;
10525
+ } finally {
10526
+ lastBrowserConsole = domSession.browserConsoleBuffer;
10527
+ await closeCaptureSession7(domSession);
10528
+ domSessionClosed = true;
10793
10529
  }
10794
- return {
10795
- success: false,
10796
- error: `audioPadTrim: AAC true peak remained above ${AAC_DELIVERY_TRUE_PEAK_DBFS} dBFS after ${MAX_TRUE_PEAK_CORRECTION_PASSES} correction passes; ${measurements.join("; ")}`
10797
- };
10798
- } catch (err) {
10799
- return {
10800
- success: false,
10801
- error: `audioPadTrim: failed to validate AAC true peak: ${sanitizeProbeFailure(
10802
- err,
10803
- scratchDir ? [input.audioPath, scratchDir] : [input.audioPath]
10804
- )}`
10805
- };
10530
+ const encodeFrom = job.progress;
10531
+ const hdrEncodeResult = await hdrEncoder.close(
10532
+ (frames) => reportEncodeProgress(job, frames, totalFrames, onProgress, encodeFrom)
10533
+ );
10534
+ hdrEncoderClosed = true;
10535
+ assertNotAborted();
10536
+ if (!hdrEncodeResult.success) {
10537
+ throw encoderFailureError("HDR encode failed", hdrEncodeResult);
10538
+ }
10539
+ reportEncodeProgress(job, totalFrames, totalFrames, onProgress, encodeFrom);
10540
+ captureDurationMs = Date.now() - stageStart;
10541
+ encodeMs = hdrEncodeResult.durationMs;
10542
+ } catch (error) {
10543
+ lastBrowserConsole = domSession.browserConsoleBuffer;
10544
+ throw wrapCaptureStageError(error, lastBrowserConsole);
10806
10545
  } finally {
10807
- if (scratchDir) rmSync8(scratchDir, { recursive: true, force: true });
10546
+ if (hdrEncoder && !hdrEncoderClosed) {
10547
+ try {
10548
+ await hdrEncoder.close();
10549
+ } catch (err) {
10550
+ log.warn("hdrEncoder defensive close failed", {
10551
+ err: err instanceof Error ? err.message : String(err)
10552
+ });
10553
+ }
10554
+ }
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
+ });
10561
+ }
10562
+ for (const frameSource of hdrVideoFrameSources.values()) {
10563
+ cleanupHdrVideoFrameSource(frameSource, log);
10564
+ }
10565
+ hdrVideoFrameSources.clear();
10566
+ releaseHdrExtractionReservation?.();
10567
+ releaseHdrExtractionReservation = null;
10808
10568
  }
10569
+ return {
10570
+ lastBrowserConsole,
10571
+ hdrPerf,
10572
+ captureDurationMs,
10573
+ encodeMs,
10574
+ warnings: cloneCaptureWarnings2(domSession.warnings)
10575
+ };
10809
10576
  }
10810
- function failResult(outputPath, target, source, error) {
10577
+
10578
+ // src/services/render/stages/encodeStage.ts
10579
+ import {
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,
10597
+ runFfmpeg as runFfmpeg5,
10598
+ spawnStreamingEncoder as spawnStreamingEncoder4
10599
+ } from "@hyperframes/engine";
10600
+ import {
10601
+ clearGifFramesBeforeNext,
10602
+ gifClearsAfterLeavingFrameInPlace
10603
+ } from "@hyperframes/core";
10604
+
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}`;
10609
+ }
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
+ ];
10622
+ }
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
+ ];
10633
+ }
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})`);
10657
+ }
10658
+ return resolved;
10659
+ }
10660
+ function gifEncodeFailure(startTime, outputPath, error, failureReason) {
10811
10661
  return {
10812
10662
  success: false,
10813
10663
  outputPath,
10814
- targetDurationSeconds: target,
10815
- sourceDurationSeconds: source,
10816
- operation: "copy",
10817
- error
10664
+ durationMs: Date.now() - startTime,
10665
+ framesEncoded: 0,
10666
+ fileSize: 0,
10667
+ error,
10668
+ failureReason
10818
10669
  };
10819
10670
  }
10820
- async function defaultProbeVideoFrameInfo(videoPath, signal) {
10821
- const fastInfo = await runFfprobeJson(
10822
- [
10823
- "-v",
10824
- "error",
10825
- "-select_streams",
10826
- "v:0",
10827
- "-show_entries",
10828
- "stream=nb_frames,r_frame_rate",
10829
- "-of",
10830
- "json",
10831
- "--",
10832
- videoPath
10833
- ],
10834
- signal
10835
- );
10836
- let stream = fastInfo.streams?.[0];
10837
- const fastCount = Number(stream?.nb_frames);
10838
- if (stream && Number.isFinite(fastCount) && fastCount > 0) {
10839
- return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
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);
10840
10677
  }
10841
- const slowInfo = await runFfprobeJson(
10842
- [
10843
- "-v",
10844
- "error",
10845
- "-select_streams",
10846
- "v:0",
10847
- "-count_packets",
10848
- "-show_entries",
10849
- "stream=nb_read_packets,r_frame_rate",
10850
- "-of",
10851
- "json",
10852
- "--",
10853
- videoPath
10854
- ],
10855
- signal
10856
- );
10857
- stream = slowInfo.streams?.[0];
10858
- if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
10859
- const slowCount = Number(stream.nb_read_packets);
10860
- if (!Number.isFinite(slowCount) || slowCount <= 0) {
10861
- throw new Error(`ffprobe returned no frame count: ${JSON.stringify(stream)}`);
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);
10862
10682
  }
10863
- return { frameCount: slowCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
10683
+ writeFileSync4(outputPath, gif);
10684
+ return null;
10864
10685
  }
10865
- function parseFrameRate(rate) {
10866
- const [n, d] = rate.split("/");
10867
- const fpsNum = Number(n);
10868
- const fpsDen = d === void 0 ? 1 : Number(d);
10869
- if (!Number.isFinite(fpsNum) || !Number.isFinite(fpsDen) || fpsNum <= 0 || fpsDen <= 0) {
10870
- throw new Error(`Invalid r_frame_rate: ${JSON.stringify(rate)}`);
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");
10871
10692
  }
10872
- return { fpsNum, fpsDen };
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
10701
+ };
10702
+ try {
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);
10706
+ if (!result.success) {
10707
+ const error = formatFfmpegError(result.exitCode, result.stderr);
10708
+ return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
10709
+ }
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;
10716
+ return {
10717
+ success: true,
10718
+ outputPath,
10719
+ durationMs: Date.now() - startTime,
10720
+ framesEncoded: frameCount,
10721
+ fileSize
10722
+ };
10723
+ } finally {
10724
+ rmSync9(input.palettePath, { force: true });
10725
+ }
10726
+ }
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);
10873
10734
  }
10874
- async function defaultProbeAudioInfo(audioPath, signal) {
10875
- const metadata = await extractAudioMetadata3(audioPath, { signal });
10735
+ function resolveEngineConfig(input) {
10736
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10737
+ }
10738
+ function videoEncoderOptions(input, engineCfg) {
10739
+ const { job, preset } = input;
10876
10740
  return {
10877
- durationSeconds: metadata.durationSeconds,
10878
- sampleRate: metadata.sampleRate,
10879
- channels: metadata.channels,
10880
- 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
10881
10757
  };
10882
10758
  }
10883
- async function defaultRunFfmpeg(args, timeoutMs, signal) {
10884
- 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;
10885
10761
  }
10886
- function defaultAacEncoder(timeoutMs, signal, pcmSourcePath) {
10887
- return async (inputPath, outputPath, durationSeconds, filter) => {
10888
- const options = pcmSourcePath ? { signal, timeout: timeoutMs, filter } : { signal, timeout: timeoutMs, filter, segments: 1 };
10889
- return toStepResult(
10890
- await encodeAac(pcmSourcePath ?? inputPath, outputPath, durationSeconds, options),
10891
- timeoutMs
10892
- );
10893
- };
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);
10894
10771
  }
10895
- function toStepResult(result, timeoutMs) {
10896
- 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();
10897
10813
  return {
10898
- success: false,
10899
- error: `[audioPadTrim] ${describeFfmpegFailure2(result, timeoutMs)}`,
10900
- 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
+ }
10901
10851
  };
10902
10852
  }
10903
- async function runFfprobeJson(args, signal) {
10904
- if (!args.includes("--")) {
10905
- throw new Error('[audioPadTrim] ffprobe args must terminate options with "--".');
10906
- }
10907
- const proc = spawn(getFfprobeBinary(), args, {
10908
- stdio: ["ignore", "pipe", "pipe"],
10909
- windowsHide: true
10910
- });
10911
- trackChildProcess(proc);
10912
- let stdout = "";
10913
- proc.stdout.on("data", (data) => {
10914
- stdout += data.toString();
10915
- });
10916
- const managed = new ManagedChildProcess(proc, {
10917
- signal,
10918
- deadlineAtMs: Date.now() + 3e4
10919
- });
10920
- const outcome = await managed.wait();
10921
- if (outcome.reason === "spawn_error") {
10922
- if (outcome.error?.code === "ENOENT") {
10923
- 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
+ );
10924
10880
  }
10925
- 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 };
10926
10893
  }
10927
- if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
10928
- const probed = args[args.length - 1];
10929
- const scrubbed = redactKnownPaths(outcome.stderr, probed === void 0 ? [] : [probed]);
10930
- 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 };
10931
10917
  }
10932
- try {
10933
- return JSON.parse(stdout);
10934
- } catch (err) {
10935
- 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);
10936
10946
  }
10947
+ onFramesEncoded();
10948
+ return { encodeMs: Date.now() - stage5Start };
10937
10949
  }
10938
10950
 
10939
10951
  // src/services/render/stages/assembleStage.ts
10952
+ import { applyFaststart, muxVideoWithAudio, packageHls } from "@hyperframes/engine";
10953
+ import { extname as extname3 } from "node:path";
10940
10954
  function recordLimiterAttenuation(job, audioLoweredDb) {
10941
10955
  if (audioLoweredDb === void 0) return;
10942
10956
  job.audioLoweredDb = audioLoweredDb;
@@ -11102,7 +11116,7 @@ function sampleDirectoryBytes(dir) {
11102
11116
  continue;
11103
11117
  }
11104
11118
  for (const name of entries) {
11105
- const full = join26(current, name);
11119
+ const full = join25(current, name);
11106
11120
  try {
11107
11121
  const st = statSync6(full);
11108
11122
  if (st.isDirectory()) {
@@ -11250,7 +11264,7 @@ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
11250
11264
  const ranges = [];
11251
11265
  let rangeStart = null;
11252
11266
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
11253
- const framePath = join26(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11267
+ const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11254
11268
  const missing = !existsSync16(framePath) || statSync6(framePath).size <= 8;
11255
11269
  if (missing && rangeStart === null) {
11256
11270
  rangeStart = frameIndex;
@@ -11273,7 +11287,7 @@ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt, ran
11273
11287
  workerId,
11274
11288
  startFrame: rangeStart + range.startFrame,
11275
11289
  endFrame: rangeStart + range.endFrame,
11276
- outputDir: join26(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
11290
+ outputDir: join25(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`),
11277
11291
  outputFrameOffset: rangeStart
11278
11292
  }));
11279
11293
  batches.push(batch);
@@ -11301,8 +11315,8 @@ function getNextRetryWorkerCount(currentWorkers) {
11301
11315
  return Math.max(1, Math.floor(currentWorkers / 2));
11302
11316
  }
11303
11317
  function resolveRenderWorkDirPrefix(outputPath, jobId, platform2 = process.platform, systemTempDir = tmpdir2()) {
11304
- if (platform2 === "win32" || platform2 === "darwin") return join26(systemTempDir, "hf-render-");
11305
- return join26(dirname10(outputPath), `work-${jobId}-`);
11318
+ if (platform2 === "win32" || platform2 === "darwin") return join25(systemTempDir, "hf-render-");
11319
+ return join25(dirname10(outputPath), `work-${jobId}-`);
11306
11320
  }
11307
11321
  var MAX_TRANSIENT_CAPTURE_RETRIES = 1;
11308
11322
  function isTransientCaptureRetryEligible(failureKind, missing, retriesUsed) {
@@ -11334,7 +11348,7 @@ The composition is too large for the available memory. To reduce memory pressure
11334
11348
  function countCapturedFrames(totalFrames, framesDir, frameExt) {
11335
11349
  let captured = 0;
11336
11350
  for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
11337
- const framePath = join26(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11351
+ const framePath = join25(framesDir, formatCaptureFrameName(frameIndex, frameExt));
11338
11352
  if (existsSync16(framePath)) captured++;
11339
11353
  }
11340
11354
  return captured;
@@ -11360,7 +11374,7 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
11360
11374
  reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry"
11361
11375
  });
11362
11376
  pendingTransientRetry = false;
11363
- const attemptWorkDir = join26(options.workDir, `capture-attempt-${attempt}`);
11377
+ const attemptWorkDir = join25(options.workDir, `capture-attempt-${attempt}`);
11364
11378
  const batches = missingRanges ? buildMissingFrameRetryBatches(
11365
11379
  missingRanges,
11366
11380
  currentWorkers,
@@ -11929,17 +11943,17 @@ function deVerifyFallbackTelemetry(err) {
11929
11943
  }
11930
11944
  function resolveRenderDebugDir() {
11931
11945
  const producerRoot = process.env.PRODUCER_RENDERS_DIR ? resolve7(process.env.PRODUCER_RENDERS_DIR, "..") : resolve7(dirname10(fileURLToPath4(import.meta.url)), "../..");
11932
- return join26(producerRoot, ".debug");
11946
+ return join25(producerRoot, ".debug");
11933
11947
  }
11934
11948
  async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
11935
11949
  validateHlsRenderConfig(job.config);
11936
11950
  const debugDir = resolveRenderDebugDir();
11937
11951
  const outputDir = dirname10(outputPath);
11938
11952
  if (!existsSync16(outputDir)) mkdirSync9(outputDir, { recursive: true });
11939
- 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);
11940
11954
  const pipelineStart = Date.now();
11941
11955
  const baseLog = job.config.logger ?? defaultLogger;
11942
- const logPath = job.config.debug ? join26(workDir, "render.log") : null;
11956
+ const logPath = job.config.debug ? join25(workDir, "render.log") : null;
11943
11957
  const execution = new RenderExecutionContext({
11944
11958
  request: { renderJobId: job.id, projectDir, outputPath },
11945
11959
  logger: logPath ? createRenderFileLogger(logPath, baseLog) : baseLog,
@@ -11953,7 +11967,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
11953
11967
  log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
11954
11968
  return;
11955
11969
  }
11956
- rmSync9(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
11970
+ rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
11957
11971
  });
11958
11972
  try {
11959
11973
  await executeRenderPipeline({
@@ -11997,7 +12011,7 @@ async function executeRenderPipeline(input) {
11997
12011
  imageDecodeFailures: 0
11998
12012
  };
11999
12013
  let hdrPerf;
12000
- const perfOutputPath = join26(workDir, "perf-summary.json");
12014
+ const perfOutputPath = join25(workDir, "perf-summary.json");
12001
12015
  const cfg = { ...job.config.producerConfig ?? resolveConfig2() };
12002
12016
  const observability = new RenderObservabilityRecorder({
12003
12017
  pipelineStartMs: pipelineStart,
@@ -12096,15 +12110,15 @@ async function executeRenderPipeline(input) {
12096
12110
  requestedWorkers: job.config.workers ?? "auto"
12097
12111
  });
12098
12112
  const entryFile = job.config.entryFile || "index.html";
12099
- let htmlPath = join26(projectDir, entryFile);
12113
+ let htmlPath = join25(projectDir, entryFile);
12100
12114
  if (!existsSync16(htmlPath)) {
12101
12115
  throw new Error(`Entry file not found: ${htmlPath}`);
12102
12116
  }
12103
12117
  assertNotAborted();
12104
12118
  const rawEntry = readFileSync8(htmlPath, "utf-8");
12105
12119
  if (entryFile !== "index.html" && isTemplateEntry(rawEntry)) {
12106
- const wrapperPath = join26(workDir, "standalone-entry.html");
12107
- const projectIndexPath = join26(projectDir, "index.html");
12120
+ const wrapperPath = join25(workDir, "standalone-entry.html");
12121
+ const projectIndexPath = join25(projectDir, "index.html");
12108
12122
  if (!existsSync16(projectIndexPath)) {
12109
12123
  throw new Error(
12110
12124
  `Template entry file "${entryFile}" requires a project index.html to extract its render shell.`
@@ -12267,7 +12281,7 @@ async function executeRenderPipeline(input) {
12267
12281
  updateJobStatus(job, "preprocessing", "Extracting video frames", 10, onProgress, {
12268
12282
  code: "extract_video"
12269
12283
  });
12270
- const compiledDir = join26(workDir, "compiled");
12284
+ const compiledDir = join25(workDir, "compiled");
12271
12285
  const extractResult = await observeRenderStage(
12272
12286
  observability,
12273
12287
  "video_extract",
@@ -12355,7 +12369,8 @@ async function executeRenderPipeline(input) {
12355
12369
  projectDir,
12356
12370
  workDir,
12357
12371
  compiledDir,
12358
- duration: probeResult.duration,
12372
+ totalFrames: probeResult.totalFrames,
12373
+ fps: job.config.fps,
12359
12374
  ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
12360
12375
  audioGain: cfg.audioGain,
12361
12376
  audios: composition.audios,
@@ -12367,6 +12382,7 @@ async function executeRenderPipeline(input) {
12367
12382
  );
12368
12383
  const { audioOutputPath, hasAudio } = audioResult;
12369
12384
  perfStages.audioProcessMs = audioResult.audioProcessMs;
12385
+ recordLimiterAttenuation(job, audioResult.audioLoweredDb);
12370
12386
  if (audioResult.audioError) {
12371
12387
  const audioFailures = audioResult.audioFailures ?? [];
12372
12388
  const failureOwner = audioFailures.length === 0 ? void 0 : audioFailures.some((failure) => failure.owner === "system") ? "system" : "user";
@@ -12399,7 +12415,7 @@ async function executeRenderPipeline(input) {
12399
12415
  try {
12400
12416
  fileServer = await createFileServer({
12401
12417
  projectDir,
12402
- compiledDir: join26(workDir, "compiled"),
12418
+ compiledDir: join25(workDir, "compiled"),
12403
12419
  port: 0,
12404
12420
  preHeadScripts: [VIRTUAL_TIME_SHIM],
12405
12421
  fps: job.config.fps
@@ -12417,7 +12433,7 @@ async function executeRenderPipeline(input) {
12417
12433
  if (!activeFileServer) {
12418
12434
  throw new Error("File server failed to initialize before frame capture");
12419
12435
  }
12420
- const framesDir = join26(workDir, "captured-frames");
12436
+ const framesDir = join25(workDir, "captured-frames");
12421
12437
  if (!existsSync16(framesDir)) mkdirSync9(framesDir, { recursive: true });
12422
12438
  updateJobStatus(job, "rendering", "Checking browser GPU", 25, onProgress, {
12423
12439
  code: "start_browsers"
@@ -12867,7 +12883,7 @@ async function executeRenderPipeline(input) {
12867
12883
  hls: ".mp4"
12868
12884
  };
12869
12885
  const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
12870
- const videoOnlyPath = join26(workDir, `video-only${videoExt}`);
12886
+ const videoOnlyPath = join25(workDir, `video-only${videoExt}`);
12871
12887
  const usePageSideCompositingForTransitions = (cfg.enablePageSideCompositing || isGif) && compiled.hasShaderTransitions && !hasHdrContent && outputSupportsPageSideShaderCompositing(outputFormat);
12872
12888
  if (usePageSideCompositingForTransitions) {
12873
12889
  activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
@@ -13110,9 +13126,10 @@ async function executeRenderPipeline(input) {
13110
13126
  outputWidth: captureCompositionWidth ?? width,
13111
13127
  outputHeight: captureCompositionHeight ?? height,
13112
13128
  motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "",
13129
+ pageMotionBlur: job.config.pageMotionBlur,
13113
13130
  captureQuality: captureOptions.quality
13114
13131
  });
13115
- const segmentDir = segmentDirFor(join26(projectDir, "renders"), segmentPlanHash);
13132
+ const segmentDir = segmentDirFor(join25(projectDir, "renders"), segmentPlanHash);
13116
13133
  let completedSegments = /* @__PURE__ */ new Set();
13117
13134
  if (job.config.resumeSegments === true) {
13118
13135
  const existing = readSegmentManifest(segmentDir);
@@ -13127,10 +13144,10 @@ async function executeRenderPipeline(input) {
13127
13144
  });
13128
13145
  }
13129
13146
  if (completedSegments.size === 0) {
13130
- rmSync9(segmentDir, { recursive: true, force: true });
13147
+ rmSync10(segmentDir, { recursive: true, force: true });
13131
13148
  }
13132
13149
  } else {
13133
- rmSync9(segmentDir, { recursive: true, force: true });
13150
+ rmSync10(segmentDir, { recursive: true, force: true });
13134
13151
  }
13135
13152
  const segmentManifest = readSegmentManifest(segmentDir) ?? {
13136
13153
  version: 1,
@@ -13211,7 +13228,7 @@ async function executeRenderPipeline(input) {
13211
13228
  );
13212
13229
  updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
13213
13230
  if (job.config.keepSegments !== true) {
13214
- rmSync9(segmentDir, { recursive: true, force: true });
13231
+ rmSync10(segmentDir, { recursive: true, force: true });
13215
13232
  }
13216
13233
  } else {
13217
13234
  capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
@@ -13480,7 +13497,7 @@ async function executeRenderPipeline(input) {
13480
13497
  "capture_disk",
13481
13498
  "drawElement capture failed; retrying with a fresh screenshot session"
13482
13499
  );
13483
- rmSync9(framesDir, { recursive: true, force: true });
13500
+ rmSync10(framesDir, { recursive: true, force: true });
13484
13501
  mkdirSync9(framesDir, { recursive: true });
13485
13502
  resetCaptureAttemptProgress(job);
13486
13503
  dedupPerfs.length = 0;
@@ -13667,7 +13684,7 @@ async function executeRenderPipeline(input) {
13667
13684
  }
13668
13685
  if (job.config.debug) {
13669
13686
  if (!isPngSequence && !isHls && existsSync16(stagedOutputPath)) {
13670
- const debugOutput = join26(workDir, `output${videoExt}`);
13687
+ const debugOutput = join25(workDir, `output${videoExt}`);
13671
13688
  copyFileSync4(stagedOutputPath, debugOutput);
13672
13689
  }
13673
13690
  }
@@ -13779,7 +13796,7 @@ import {
13779
13796
  closeSync as closeSync5,
13780
13797
  constants as constants3
13781
13798
  } from "node:fs";
13782
- 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";
13783
13800
  import { lintHyperframeHtml } from "@hyperframes/lint";
13784
13801
  function isStringRecord(value) {
13785
13802
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -13847,7 +13864,7 @@ function readProjectEntryFile(projectDir, preferredEntryFile) {
13847
13864
  }
13848
13865
  }
13849
13866
  return {
13850
- 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")}`
13851
13868
  };
13852
13869
  }
13853
13870
  function prepareHyperframeLintBody(body) {
@@ -13893,7 +13910,7 @@ async function runHyperframeLint(prepared) {
13893
13910
  // src/services/healthWorker.ts
13894
13911
  import { Worker as Worker2 } from "node:worker_threads";
13895
13912
  import { fileURLToPath as fileURLToPath5 } from "node:url";
13896
- import { dirname as dirname11, join as join28 } from "node:path";
13913
+ import { dirname as dirname11, join as join27 } from "node:path";
13897
13914
  import { existsSync as existsSync18 } from "node:fs";
13898
13915
  var DEFAULT_HEALTH_PORT = 9848;
13899
13916
  async function startHealthWorker(options = {}) {
@@ -13969,7 +13986,7 @@ function defaultLogger2() {
13969
13986
  }
13970
13987
  function resolveWorkerEntry2() {
13971
13988
  const here = dirname11(fileURLToPath5(import.meta.url));
13972
- const candidates = [join28(here, "healthWorkerThread.js"), join28(here, "healthWorkerThread.ts")];
13989
+ const candidates = [join27(here, "healthWorkerThread.js"), join27(here, "healthWorkerThread.ts")];
13973
13990
  for (const candidate of candidates) {
13974
13991
  if (existsSync18(candidate)) return candidate;
13975
13992
  }
@@ -14005,7 +14022,7 @@ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
14005
14022
 
14006
14023
  // src/services/distributed/plan.ts
14007
14024
  import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
14008
- 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";
14009
14026
  import {
14010
14027
  fpsToNumber as fpsToNumber10,
14011
14028
  HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
@@ -14102,9 +14119,9 @@ var PLAN_ROOT_CATEGORY = {
14102
14119
  var PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
14103
14120
  var FREEZE_OWNED_PLAN_FILES = [
14104
14121
  "plan.json",
14105
- join29("meta", "composition.json"),
14106
- join29("meta", "encoder.json"),
14107
- join29("meta", "chunks.json")
14122
+ join28("meta", "composition.json"),
14123
+ join28("meta", "encoder.json"),
14124
+ join28("meta", "chunks.json")
14108
14125
  ];
14109
14126
 
14110
14127
  // src/services/distributed/renderConfigValidation.ts
@@ -14454,6 +14471,7 @@ function buildRenderJobConfig(input, outputPath, log) {
14454
14471
  outputResolution: input.outputResolution,
14455
14472
  outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
14456
14473
  videoFrameFormat: input.videoFrameFormat,
14474
+ pageMotionBlur: input.pageMotionBlur,
14457
14475
  hdrMode: toRenderHdrMode(input.outputDynamicRange)
14458
14476
  }
14459
14477
  });
@@ -14529,8 +14547,8 @@ async function resolveInlineRenderHtml(body) {
14529
14547
  }
14530
14548
  function materializeInlineProject(html, options) {
14531
14549
  const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir3();
14532
- const tempProjectDir = mkdtempSync4(join30(tempRoot, "producer-project-"));
14533
- 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");
14534
14552
  return {
14535
14553
  prepared: {
14536
14554
  input: { projectDir: tempProjectDir, ...options },
@@ -14541,7 +14559,11 @@ function materializeInlineProject(html, options) {
14541
14559
  async function prepareRenderBody(body) {
14542
14560
  const overrideError = validateRenderOverrides(body);
14543
14561
  if (overrideError) return { error: overrideError };
14544
- 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 };
14545
14567
  const project = prepareProjectDirectory(body.projectDir, options);
14546
14568
  if (project) return project;
14547
14569
  const source = await resolveInlineRenderHtml(body);
@@ -14587,7 +14609,7 @@ function createArtifactStore(ttlMs) {
14587
14609
  function cleanupTempDir(dir, log) {
14588
14610
  if (!dir) return;
14589
14611
  try {
14590
- rmSync10(dir, { recursive: true, force: true });
14612
+ rmSync11(dir, { recursive: true, force: true });
14591
14613
  } catch (error) {
14592
14614
  log.warn("Failed to cleanup temp project dir", {
14593
14615
  cleanupProjectDir: dir,