@hyperframes/producer 0.8.206 → 0.8.207

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,7 +7,7 @@ import {
7
7
  statSync as statSync10,
8
8
  mkdtempSync as mkdtempSync4,
9
9
  writeFileSync as writeFileSync7,
10
- rmSync as rmSync13,
10
+ rmSync as rmSync11,
11
11
  createReadStream as createReadStream3
12
12
  } from "node:fs";
13
13
  import { resolve as resolve10, dirname as dirname13, join as join31 } from "node:path";
@@ -26,7 +26,7 @@ import {
26
26
  mkdirSync as mkdirSync10,
27
27
  readFileSync as readFileSync9,
28
28
  readdirSync as readdirSync5,
29
- rmSync as rmSync12,
29
+ rmSync as rmSync10,
30
30
  statSync as statSync8,
31
31
  writeFileSync as writeFileSync6,
32
32
  copyFileSync as copyFileSync5,
@@ -35,7 +35,7 @@ import {
35
35
  import { tmpdir as tmpdir2 } from "node:os";
36
36
  import {
37
37
  chainCapture,
38
- fpsToNumber as fpsToNumber9,
38
+ fpsToNumber as fpsToNumber8,
39
39
  HF_COLOR_GRADING_ATTR,
40
40
  HF_VFX_ATTR,
41
41
  parseVfxChain,
@@ -831,7 +831,7 @@ function outputUsesH264Pipeline(format) {
831
831
  }
832
832
 
833
833
  // src/services/render/hlsConfig.ts
834
- import { fpsToNumber as fpsToNumber2 } from "@hyperframes/core";
834
+ import { keyframeIntervalToGopSize } from "@hyperframes/engine";
835
835
  var DEFAULT_HLS_SEGMENT_SECONDS = 4;
836
836
  function validateHlsRenderConfig(config) {
837
837
  if (config.format !== "hls") return;
@@ -856,7 +856,7 @@ function resolveHlsSegmentSeconds(config) {
856
856
  return config.hlsSegmentSeconds ?? DEFAULT_HLS_SEGMENT_SECONDS;
857
857
  }
858
858
  function resolveHlsGopSize(fps, segmentSeconds) {
859
- return Math.max(1, Math.round(fpsToNumber2(fps) * segmentSeconds));
859
+ return keyframeIntervalToGopSize(fps, segmentSeconds);
860
860
  }
861
861
  function resolveHlsEncoderGopLock(outputFormat, fps, segmentSeconds) {
862
862
  if (outputFormat !== "hls") return { lockGopForChunkConcat: false, gopSize: void 0 };
@@ -866,6 +866,38 @@ function resolveHlsEncoderGopLock(outputFormat, fps, segmentSeconds) {
866
866
  };
867
867
  }
868
868
 
869
+ // src/services/render/deliveryEncoder.ts
870
+ import { appendRateCapArgs, keyframeIntervalToGopSize as keyframeIntervalToGopSize2 } from "@hyperframes/engine";
871
+ function validateDeliveryEncoderConfig(config) {
872
+ const { keyframeIntervalSeconds, maxBitrate, bufferSize, format } = config;
873
+ if (keyframeIntervalSeconds === void 0 && maxBitrate === void 0 && bufferSize === void 0)
874
+ return;
875
+ if (keyframeIntervalSeconds !== void 0) {
876
+ if (!Number.isFinite(keyframeIntervalSeconds) || keyframeIntervalSeconds <= 0) {
877
+ throw new Error(
878
+ `keyframeIntervalSeconds must be a positive number of seconds; got ${keyframeIntervalSeconds}`
879
+ );
880
+ }
881
+ if (format === "hls") {
882
+ throw new Error(
883
+ "keyframeIntervalSeconds does not apply to hls; hlsSegmentSeconds sets its keyframes"
884
+ );
885
+ }
886
+ }
887
+ if (format !== void 0 && format !== "mp4") {
888
+ throw new Error(`keyframeIntervalSeconds and maxBitrate apply to mp4 renders, not ${format}`);
889
+ }
890
+ appendRateCapArgs([], { maxBitrate, bufferSize });
891
+ }
892
+ function resolveDeliveryEncoderOptions(config) {
893
+ const { maxBitrate, bufferSize, keyframeIntervalSeconds } = config;
894
+ const gop = keyframeIntervalSeconds === void 0 ? resolveHlsEncoderGopLock(config.format ?? "mp4", config.fps, config.hlsSegmentSeconds) : {
895
+ lockGopForChunkConcat: true,
896
+ gopSize: keyframeIntervalToGopSize2(config.fps, keyframeIntervalSeconds)
897
+ };
898
+ return { maxBitrate, bufferSize, ...gop };
899
+ }
900
+
869
901
  // src/services/render/shared.ts
870
902
  import {
871
903
  copyFileSync,
@@ -1884,7 +1916,7 @@ function replanSegmentedAfterFailure(plan, failure) {
1884
1916
  const keepSegmenting = failure.kind === "draw_element_verification" || failure.kind === "draw_element_capture";
1885
1917
  return createCapturePlan({
1886
1918
  ...plan,
1887
- forceScreenshot: keepSegmenting || failure.kind === "capture_failure",
1919
+ forceScreenshot: plan.forceScreenshot || keepSegmenting || failure.kind === "capture_failure",
1888
1920
  useStreamingEncode: true,
1889
1921
  useLayeredComposite: false,
1890
1922
  useSegmentedCapture: keepSegmenting,
@@ -1954,7 +1986,7 @@ import { CaptureFailure, classifyCaptureFailure } from "@hyperframes/engine";
1954
1986
 
1955
1987
  // src/services/render/perfSummary.ts
1956
1988
  import { arch, cpus, platform, totalmem } from "node:os";
1957
- import { fpsToNumber as fpsToNumber3 } from "@hyperframes/core";
1989
+ import { fpsToNumber as fpsToNumber2 } from "@hyperframes/core";
1958
1990
 
1959
1991
  // src/services/render/hdrPerf.ts
1960
1992
  function createHdrPerfCollector() {
@@ -2225,7 +2257,7 @@ function buildRenderPerfSummary(input) {
2225
2257
  // payloads (CLI telemetry, regression-harness reports) where a single
2226
2258
  // number is friendlier than `{num,den}`. Callers needing the rational
2227
2259
  // back can read `job.config.fps`.
2228
- fps: fpsToNumber3(input.job.config.fps),
2260
+ fps: fpsToNumber2(input.job.config.fps),
2229
2261
  quality: input.job.config.quality,
2230
2262
  workers: input.workerCount,
2231
2263
  workerSizing: input.workerSizing,
@@ -2810,7 +2842,7 @@ import {
2810
2842
  resolveVideoExtractionWindow,
2811
2843
  runFfmpeg
2812
2844
  } from "@hyperframes/engine";
2813
- import { fpsToFfmpegArg, fpsToNumber as fpsToNumber4 } from "@hyperframes/core";
2845
+ import { fpsToFfmpegArg, fpsToNumber as fpsToNumber3 } from "@hyperframes/core";
2814
2846
 
2815
2847
  // src/services/hdrFrameWindows.ts
2816
2848
  import { closeSync as closeSync3, constants as constants2, openSync as openSync3, rmSync as rmSync4 } from "node:fs";
@@ -3113,7 +3145,7 @@ async function extractHdrVideoFrames(args) {
3113
3145
  const runFfmpegImpl = args.runFfmpegImpl ?? runFfmpeg;
3114
3146
  const extractMediaMetadataImpl = args.extractMediaMetadataImpl ?? extractMediaMetadata2;
3115
3147
  const resolveFinalFrameExtractionWindowImpl = args.resolveFinalFrameExtractionWindowImpl ?? resolveFinalFrameExtractionWindow;
3116
- const fps = fpsToNumber4(job.config.fps);
3148
+ const fps = fpsToNumber3(job.config.fps);
3117
3149
  const out = /* @__PURE__ */ new Map();
3118
3150
  mkdirSync2(framesDir, { recursive: true });
3119
3151
  let scratchBytes = 0;
@@ -3951,7 +3983,6 @@ async function runCaptureStreamingStage(input) {
3951
3983
  const reorderBuffer = createFrameReorderBuffer(0, totalFrames);
3952
3984
  if (workerCount > 1) {
3953
3985
  const deParallelStream = forceParallelStream === true || process.env.HF_DE_PARALLEL_STREAM === "true";
3954
- const tasks = deParallelStream ? distributeFramesInterleaved(totalFrames, workerCount, workDir) : distributeFrames(totalFrames, workerCount, workDir);
3955
3986
  const parallelStats = {
3956
3987
  verifyChecked: 0,
3957
3988
  blankSuspects: 0,
@@ -3964,143 +3995,158 @@ async function runCaptureStreamingStage(input) {
3964
3995
  frameTime: (i) => i * job.config.fps.den / job.config.fps.num
3965
3996
  });
3966
3997
  let parallelGuardRan = false;
3967
- let parallelDrainError = null;
3968
- const onFrameBuffer = async (frameIndex, buffer, workerSession) => {
3969
- try {
3970
- if (deParallelStream && workerSession.captureMode === "drawelement") {
3971
- parallelGuardRan = true;
3972
- buffer = await parallelGuard(workerSession, frameIndex, buffer);
3973
- }
3974
- await reorderBuffer.waitForFrame(frameIndex);
3975
- ensureFrameWritten(await currentEncoder.writeFrame(buffer), frameIndex, currentEncoder);
3976
- reorderBuffer.advanceTo(frameIndex + 1);
3977
- } catch (err) {
3978
- const e = err instanceof Error ? err : new Error(String(err));
3979
- if (!parallelDrainError) {
3980
- parallelDrainError = e;
3981
- reorderBuffer.abort(e);
3998
+ let writtenFrames = 0;
3999
+ let resumeFrom = 0;
4000
+ let resumedAfterStall = false;
4001
+ let workerResults;
4002
+ for (; ; ) {
4003
+ const parallelReorder = createFrameReorderBuffer(resumeFrom, totalFrames);
4004
+ const tasks = deParallelStream ? distributeFramesInterleaved(totalFrames - resumeFrom, workerCount, workDir, resumeFrom) : distributeFrames(totalFrames - resumeFrom, workerCount, workDir, resumeFrom);
4005
+ let parallelDrainError = null;
4006
+ const onFrameBuffer = async (frameIndex, buffer, workerSession) => {
4007
+ try {
4008
+ if (deParallelStream && workerSession.captureMode === "drawelement") {
4009
+ parallelGuardRan = true;
4010
+ buffer = await parallelGuard(workerSession, frameIndex, buffer);
4011
+ }
4012
+ await parallelReorder.waitForFrame(frameIndex);
4013
+ ensureFrameWritten(await currentEncoder.writeFrame(buffer), frameIndex, currentEncoder);
4014
+ parallelReorder.advanceTo(frameIndex + 1);
4015
+ writtenFrames = frameIndex + 1;
4016
+ } catch (err) {
4017
+ const e = err instanceof Error ? err : new Error(String(err));
4018
+ if (!parallelDrainError) {
4019
+ parallelDrainError = e;
4020
+ parallelReorder.abort(e);
4021
+ }
4022
+ throw e;
3982
4023
  }
3983
- throw e;
4024
+ };
4025
+ const captureLabel = input.parallelCaptureLabel ?? "worker";
4026
+ let watchdogArmed = false;
4027
+ const stallController = new AbortController();
4028
+ const forwardParentAbort = () => stallController.abort();
4029
+ if (abortSignal) {
4030
+ if (abortSignal.aborted) stallController.abort();
4031
+ else abortSignal.addEventListener("abort", forwardParentAbort, { once: true });
3984
4032
  }
3985
- };
3986
- const captureLabel = input.parallelCaptureLabel ?? "worker";
3987
- let watchdogArmed = false;
3988
- const stallController = new AbortController();
3989
- const forwardParentAbort = () => stallController.abort();
3990
- if (abortSignal) {
3991
- if (abortSignal.aborted) stallController.abort();
3992
- else abortSignal.addEventListener("abort", forwardParentAbort, { once: true });
3993
- }
3994
- const stallTimeoutMs = resolveCaptureStallTimeoutMs();
3995
- let lastCapturedFrames = 0;
3996
- let lastProgressAt = Date.now();
3997
- const workerPhases = /* @__PURE__ */ new Map();
3998
- const phaseSummary = () => [...workerPhases.entries()].sort(([left], [right]) => left - right).map(([workerId, detail]) => `worker=${workerId} ${detail}`).join("; ");
3999
- let stalled = false;
4000
- const stallTimer = setInterval(
4001
- () => {
4002
- if (!watchdogArmed || Date.now() - lastProgressAt <= stallTimeoutMs) return;
4003
- stalled = true;
4004
- const stallErr = new ParallelCaptureStallError({
4005
- variant: "drain",
4006
- captureLabel,
4007
- stallTimeoutMs,
4008
- lastCapturedFrames,
4009
- totalFrames,
4010
- phaseSummary: phaseSummary()
4011
- });
4012
- reorderBuffer.abort(stallErr);
4013
- stallController.abort();
4014
- },
4015
- Math.min(stallTimeoutMs, DE_STALL_POLL_MS)
4016
- );
4017
- let workerResults;
4018
- try {
4019
- workerResults = await executeParallelCapture(
4020
- fileServer.url,
4021
- workDir,
4022
- tasks,
4023
- buildCaptureOptions(),
4024
- createRenderVideoFrameInjector,
4025
- stallController.signal,
4026
- (progress) => {
4027
- if (progress.latestWorkerPhase) {
4028
- const phase = progress.latestWorkerPhase;
4029
- const detail = `phase=${phase.phase} frame=${phase.frameIndex ?? "n/a"} browser=${phase.browserExecutable} version=${phase.browserVersion} CanvasDrawElement=${phase.canvasDrawElement} gpu=${phase.gpuBackend}`;
4030
- workerPhases.set(phase.workerId, detail);
4031
- if (phase.phase === "frame_capture" && !watchdogArmed) {
4032
- watchdogArmed = true;
4033
+ const stallTimeoutMs = resolveCaptureStallTimeoutMs();
4034
+ let lastCapturedFrames = 0;
4035
+ let lastProgressAt = Date.now();
4036
+ const workerPhases = /* @__PURE__ */ new Map();
4037
+ const phaseDetail = (phase) => `phase=${phase.phase} frame=${phase.frameIndex ?? "n/a"} step=${phase.readStep?.() ?? "n/a"} browser=${phase.browserExecutable} version=${phase.browserVersion} CanvasDrawElement=${phase.canvasDrawElement} gpu=${phase.gpuBackend}`;
4038
+ const phaseSummary = () => [...workerPhases.entries()].sort(([left], [right]) => left - right).map(([workerId, detail]) => `worker=${workerId} ${detail}`).join("; ");
4039
+ let stalled = false;
4040
+ const stallTimer = setInterval(
4041
+ () => {
4042
+ if (!watchdogArmed || Date.now() - lastProgressAt <= stallTimeoutMs) return;
4043
+ stalled = true;
4044
+ const stallErr = new ParallelCaptureStallError({
4045
+ variant: "drain",
4046
+ captureLabel,
4047
+ stallTimeoutMs,
4048
+ lastCapturedFrames: resumeFrom + lastCapturedFrames,
4049
+ totalFrames,
4050
+ phaseSummary: phaseSummary()
4051
+ });
4052
+ parallelReorder.abort(stallErr);
4053
+ stallController.abort();
4054
+ },
4055
+ Math.min(stallTimeoutMs, DE_STALL_POLL_MS)
4056
+ );
4057
+ try {
4058
+ workerResults = await executeParallelCapture(
4059
+ fileServer.url,
4060
+ workDir,
4061
+ tasks,
4062
+ buildCaptureOptions(),
4063
+ createRenderVideoFrameInjector,
4064
+ stallController.signal,
4065
+ (progress) => {
4066
+ if (progress.latestWorkerPhase) {
4067
+ const phase = progress.latestWorkerPhase;
4068
+ workerPhases.set(phase.workerId, phaseDetail(phase));
4069
+ if (phase.phase === "frame_capture" && !watchdogArmed) {
4070
+ watchdogArmed = true;
4071
+ lastProgressAt = Date.now();
4072
+ }
4073
+ log.info("[Render] Parallel capture worker phase", {
4074
+ workerId: phase.workerId,
4075
+ phase: phase.phase,
4076
+ frameIndex: phase.frameIndex,
4077
+ browserExecutable: phase.browserExecutable,
4078
+ browserVersion: phase.browserVersion,
4079
+ canvasDrawElement: phase.canvasDrawElement,
4080
+ gpuBackend: phase.gpuBackend
4081
+ });
4082
+ if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
4083
+ return;
4084
+ }
4085
+ if (progress.capturedFrames > lastCapturedFrames) {
4086
+ lastCapturedFrames = progress.capturedFrames;
4033
4087
  lastProgressAt = Date.now();
4088
+ watchdogArmed = true;
4089
+ }
4090
+ const doneFrames = resumeFrom + progress.capturedFrames;
4091
+ job.framesRendered = doneFrames;
4092
+ const frameProgress = doneFrames / totalFrames;
4093
+ reportFrameProgress(
4094
+ job,
4095
+ `Streaming frame ${doneFrames}/${totalFrames} (${workerCount} workers)`,
4096
+ Math.round(25 + frameProgress * 55),
4097
+ onProgress,
4098
+ doneFrames,
4099
+ totalFrames
4100
+ );
4101
+ },
4102
+ onFrameBuffer,
4103
+ // Interleaved DE workers each get their own browser process: co-tenant pages starve
4104
+ // each other's paint events.
4105
+ deParallelStream ? { ...captureCfg, enableBrowserPool: false } : captureCfg,
4106
+ {
4107
+ // A worker died: release parked peers with its original failure, not the stall the
4108
+ // watchdog raises later.
4109
+ onWorkerOperation: (phase) => workerPhases.set(phase.workerId, phaseDetail(phase)),
4110
+ onWorkerFailure: (failure) => {
4111
+ if (parallelDrainError) return;
4112
+ parallelDrainError = failure;
4113
+ parallelReorder.abort(failure);
4034
4114
  }
4035
- log.info("[Render] Parallel capture worker phase", {
4036
- workerId: phase.workerId,
4037
- phase: phase.phase,
4038
- frameIndex: phase.frameIndex,
4039
- browserExecutable: phase.browserExecutable,
4040
- browserVersion: phase.browserVersion,
4041
- canvasDrawElement: phase.canvasDrawElement,
4042
- gpuBackend: phase.gpuBackend
4043
- });
4044
- if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
4045
- return;
4046
- }
4047
- if (progress.capturedFrames > lastCapturedFrames) {
4048
- lastCapturedFrames = progress.capturedFrames;
4049
- lastProgressAt = Date.now();
4050
- watchdogArmed = true;
4051
4115
  }
4052
- job.framesRendered = progress.capturedFrames;
4053
- const frameProgress = progress.capturedFrames / progress.totalFrames;
4054
- reportFrameProgress(
4055
- job,
4056
- `Streaming frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
4057
- Math.round(25 + frameProgress * 55),
4058
- onProgress,
4059
- progress.capturedFrames,
4060
- progress.totalFrames
4116
+ );
4117
+ } catch (err) {
4118
+ if (stalled && abortSignal?.aborted !== true && captureLabel === "BeginFrame" && !resumedAfterStall && writtenFrames > resumeFrom) {
4119
+ resumedAfterStall = true;
4120
+ log.warn(
4121
+ "[Render] Parallel BeginFrame capture stalled; resuming from the first unwritten frame",
4122
+ {
4123
+ resumeFrom: writtenFrames,
4124
+ totalFrames,
4125
+ workerPhases: phaseSummary()
4126
+ }
4061
4127
  );
4062
- },
4063
- onFrameBuffer,
4064
- // Interleaved DE workers each need their own browser PROCESS:
4065
- // pages co-tenant in one browser share one compositor, whose
4066
- // internal frame scheduling deprioritizes non-active pages — their
4067
- // canvas `paint` events starve and the drawElement paint-wait slows
4068
- // ~3x (measured: 86s vs 30s on a 3,245-frame rAF comp). This is
4069
- // Chromium's internal frame-production scheduling on ALL platforms,
4070
- // NOT the Linux-only HeadlessExperimental.beginFrame capture mode.
4071
- deParallelStream ? { ...captureCfg, enableBrowserPool: false } : captureCfg,
4072
- {
4073
- // A worker died (Target closed, Page crashed, launch failure).
4074
- // Its frames are gone and peers are parked in waitForFrame on the
4075
- // first of them; release them with the ORIGINAL failure so the
4076
- // orchestrator sees a transient browser error it knows how to
4077
- // retry, not the stall the watchdog would have raised a minute
4078
- // later. First failure wins, same as the drain guard above.
4079
- onWorkerFailure: (failure) => {
4080
- if (parallelDrainError) return;
4081
- parallelDrainError = failure;
4082
- reorderBuffer.abort(failure);
4083
- }
4128
+ resumeFrom = writtenFrames;
4129
+ continue;
4084
4130
  }
4085
- );
4086
- } catch (err) {
4087
- if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession);
4088
- if (stalled && abortSignal?.aborted !== true) {
4089
- throw new ParallelCaptureStallError({
4090
- variant: "pool",
4091
- captureLabel,
4092
- stallTimeoutMs,
4093
- lastCapturedFrames,
4094
- totalFrames,
4095
- phaseSummary: phaseSummary()
4096
- });
4131
+ if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession);
4132
+ if (stalled && abortSignal?.aborted !== true) {
4133
+ throw new ParallelCaptureStallError({
4134
+ variant: "pool",
4135
+ captureLabel,
4136
+ stallTimeoutMs,
4137
+ lastCapturedFrames: resumeFrom + lastCapturedFrames,
4138
+ totalFrames,
4139
+ phaseSummary: phaseSummary()
4140
+ });
4141
+ }
4142
+ throw poolFailure(err, probeSession);
4143
+ } finally {
4144
+ clearInterval(stallTimer);
4145
+ abortSignal?.removeEventListener("abort", forwardParentAbort);
4097
4146
  }
4098
- throw poolFailure(err, probeSession);
4099
- } finally {
4100
- clearInterval(stallTimer);
4101
- abortSignal?.removeEventListener("abort", forwardParentAbort);
4147
+ if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession);
4148
+ break;
4102
4149
  }
4103
- if (parallelDrainError) throw poolFailure(parallelDrainError, probeSession);
4104
4150
  pushWorkerDedupPerfs(workerResults, dedupPerfs);
4105
4151
  const workerPageErrors = [];
4106
4152
  pushWorkerPageErrors(workerResults, workerPageErrors);
@@ -4261,7 +4307,7 @@ function segmentEncoderOptions(base, segment) {
4261
4307
  return {
4262
4308
  ...base,
4263
4309
  lockGopForChunkConcat: true,
4264
- gopSize: segment.endFrame - segment.startFrame
4310
+ gopSize: Math.min(base.gopSize ?? Infinity, segment.endFrame - segment.startFrame)
4265
4311
  };
4266
4312
  }
4267
4313
  var SegmentEncoderSpawnError = class extends Error {
@@ -4355,6 +4401,12 @@ function defaultSessionFactory(input) {
4355
4401
  let probeSession = input.probeSession;
4356
4402
  return {
4357
4403
  create: async () => {
4404
+ const probeCannotCaptureEveryFrame = Boolean(probeSession?.deRiskFrames?.size);
4405
+ if (probeSession && probeCannotCaptureEveryFrame) {
4406
+ await closeCaptureSession2(probeSession).catch(() => {
4407
+ });
4408
+ probeSession = null;
4409
+ }
4358
4410
  const session = probeSession ?? await openSegmentedSession(input);
4359
4411
  if (probeSession) {
4360
4412
  openSegmentedSessionReuse(input, probeSession);
@@ -4685,8 +4737,10 @@ function computeSegmentPlanHash(input) {
4685
4737
  input.motionBlur,
4686
4738
  input.pageMotionBlur ?? "",
4687
4739
  input.captureQuality === void 0 ? "" : String(input.captureQuality)
4688
- ].join(" ");
4689
- return createHash2("sha256").update(ordered).digest("hex").slice(0, 16);
4740
+ ];
4741
+ const delivery = [input.maxBitrate, input.bufferSize, input.gopSize].map((v) => v ?? "");
4742
+ if (delivery.some((v) => v !== "")) ordered.push(...delivery.map(String));
4743
+ return createHash2("sha256").update(ordered.join(" ")).digest("hex").slice(0, 16);
4690
4744
  }
4691
4745
  function segmentDirFor(projectRendersDir, planHash) {
4692
4746
  return join11(projectRendersDir, ".hf-segments", planHash);
@@ -4833,7 +4887,7 @@ function resolveVideoCaptureBeyondViewport(videoCount) {
4833
4887
 
4834
4888
  // src/services/render/captureCost.ts
4835
4889
  import { join as join12 } from "node:path";
4836
- import { fpsToNumber as fpsToNumber5 } from "@hyperframes/core";
4890
+ import { fpsToNumber as fpsToNumber4 } from "@hyperframes/core";
4837
4891
  import {
4838
4892
  calculateOptimalWorkers,
4839
4893
  captureFrameToBuffer as captureFrameToBuffer3,
@@ -5037,7 +5091,7 @@ async function runCaptureCalibration(input) {
5037
5091
  let probeSession = input.probeSession;
5038
5092
  let forceScreenshot = input.forceScreenshot;
5039
5093
  let lastBrowserConsole = [];
5040
- const fps = fpsToNumber5(job.config.fps);
5094
+ const fps = fpsToNumber4(job.config.fps);
5041
5095
  const sessionRef = { current: null };
5042
5096
  const runOneCalibration = async (sessionDir, sessionCfg) => {
5043
5097
  log.info("Launching browser for capture calibration...");
@@ -5497,7 +5551,7 @@ function emitFallbackCaptureProfile(recorder, perfSummaries, env = process.env)
5497
5551
  // src/services/render/videoFrameCoverage.ts
5498
5552
  import { parseHTML } from "linkedom";
5499
5553
  import {
5500
- fpsToNumber as fpsToNumber6,
5554
+ fpsToNumber as fpsToNumber5,
5501
5555
  normalizeRateSpec,
5502
5556
  sourceTimeAt,
5503
5557
  toFps
@@ -5532,7 +5586,7 @@ function resolveVideoCoverageThreshold(envValue = process.env.HF_VIDEO_COVERAGE_
5532
5586
  return parsed;
5533
5587
  }
5534
5588
  function expectedFramesForClip(start, end, fps, rounding = "ceil") {
5535
- const fpsValue = fpsToNumber6(toFps(fps));
5589
+ const fpsValue = fpsToNumber5(toFps(fps));
5536
5590
  if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(fpsValue)) return 0;
5537
5591
  if (fpsValue <= 0) return 0;
5538
5592
  const duration = Math.max(0, end - start);
@@ -6794,7 +6848,7 @@ function detectShaderTransitionUsage(html) {
6794
6848
  }
6795
6849
  return false;
6796
6850
  }
6797
- async function resolveMediaDuration(src, mediaStart, playbackRate, baseDir, downloadDir, tagName, elementIdentity, log, urlByAbsentLocalPath) {
6851
+ async function resolveMediaDuration(src, mediaStart, playbackRate, baseDir, downloadDir, tagName, elementIdentity, log, urlByAbsentLocalPath, probes) {
6798
6852
  let filePath = src;
6799
6853
  if (isHttpUrl(src)) {
6800
6854
  if (!existsSync10(downloadDir)) mkdirSync7(downloadDir, { recursive: true });
@@ -6821,45 +6875,52 @@ async function resolveMediaDuration(src, mediaStart, playbackRate, baseDir, down
6821
6875
  if (error instanceof Error && error.stack) wrapped.stack = error.stack;
6822
6876
  return wrapped;
6823
6877
  };
6824
- return withMediaProbeSlot(async () => {
6825
- let profile;
6878
+ const probe = () => withMediaProbeSlot(async () => {
6826
6879
  try {
6827
6880
  await assertMediaPayload(filePath, elementIdentity);
6828
- profile = await probeMediaProfile2(filePath);
6829
- } catch (error) {
6830
- if (tagName !== "video") {
6831
- if (error instanceof NotMediaPayloadError2) {
6832
- log?.warn(
6833
- `[compile] Audio "${elementIdentity}" (${src}) is a text document, not a media file, so the element is dropped from the render. Point it at a rendered media file.`
6834
- );
6835
- }
6836
- return { duration: null, resolvedPath: filePath };
6837
- }
6838
- throw withSrcContext(error);
6839
- }
6840
- assertAssetMediaTypeProfile(tagName === "video" ? "video" : "audio", profile, elementIdentity);
6841
- let metadata;
6842
- if (tagName === "video") {
6843
- try {
6844
- metadata = await extractMediaMetadata(filePath);
6845
- } catch (error) {
6846
- throw withSrcContext(error);
6881
+ const profile = await probeMediaProfile2(filePath);
6882
+ if (tagName === "video") {
6883
+ return { profile, fileDuration: (await extractMediaMetadata(filePath)).durationSeconds };
6847
6884
  }
6848
- } else {
6849
6885
  try {
6850
- metadata = await extractAudioMetadata(filePath);
6886
+ return { profile, fileDuration: (await extractAudioMetadata(filePath)).durationSeconds };
6851
6887
  } catch {
6852
- return { duration: null, resolvedPath: filePath };
6888
+ return { profile, fileDuration: null };
6853
6889
  }
6890
+ } catch (error) {
6891
+ return { error };
6854
6892
  }
6855
- const fileDuration = metadata.durationSeconds;
6856
- const duration = resolveNaturalMediaTimelineDurationFromValues(
6857
- fileDuration,
6858
- mediaStart,
6859
- playbackRate
6860
- );
6861
- return { duration, resolvedPath: filePath };
6862
6893
  });
6894
+ const key = `${tagName}\0${filePath}`;
6895
+ let probed = probes?.get(key);
6896
+ if (!probed) {
6897
+ probed = probe();
6898
+ probes?.set(key, probed);
6899
+ }
6900
+ const outcome = await probed;
6901
+ if ("error" in outcome) {
6902
+ if (tagName !== "video") {
6903
+ if (outcome.error instanceof NotMediaPayloadError2) {
6904
+ log?.warn(
6905
+ `[compile] Audio "${elementIdentity}" (${src}) is a text document, not a media file, so the element is dropped from the render. Point it at a rendered media file.`
6906
+ );
6907
+ }
6908
+ return { duration: null, resolvedPath: filePath };
6909
+ }
6910
+ throw withSrcContext(outcome.error);
6911
+ }
6912
+ assertAssetMediaTypeProfile(
6913
+ tagName === "video" ? "video" : "audio",
6914
+ outcome.profile,
6915
+ elementIdentity
6916
+ );
6917
+ if (outcome.fileDuration === null) return { duration: null, resolvedPath: filePath };
6918
+ const duration = resolveNaturalMediaTimelineDurationFromValues(
6919
+ outcome.fileDuration,
6920
+ mediaStart,
6921
+ playbackRate
6922
+ );
6923
+ return { duration, resolvedPath: filePath };
6863
6924
  }
6864
6925
  function markInferredVariableMediaDurations(html, unresolvedMedia) {
6865
6926
  const unresolvedIds = new Set(unresolvedMedia.map((element) => element.id));
@@ -6873,7 +6934,7 @@ function markInferredVariableMediaDurations(html, unresolvedMedia) {
6873
6934
  }
6874
6935
  return changed ? document2.toString() : html;
6875
6936
  }
6876
- async function compileHtmlFile(html, baseDir, downloadDir, log, urlByAbsentLocalPath) {
6937
+ async function compileHtmlFile(html, baseDir, downloadDir, log, urlByAbsentLocalPath, probes) {
6877
6938
  const { html: staticCompiled, unresolved } = compileTimingAttrs(html);
6878
6939
  const mediaUnresolved = unresolved.filter(
6879
6940
  (el) => (el.tagName === "video" || el.tagName === "audio") && el.src
@@ -6890,7 +6951,8 @@ async function compileHtmlFile(html, baseDir, downloadDir, log, urlByAbsentLocal
6890
6951
  el.tagName,
6891
6952
  el.id,
6892
6953
  log,
6893
- urlByAbsentLocalPath
6954
+ urlByAbsentLocalPath,
6955
+ probes
6894
6956
  ).then(({ duration }) => ({ id: el.id, duration }))
6895
6957
  )
6896
6958
  );
@@ -6911,7 +6973,9 @@ async function compileHtmlFile(html, baseDir, downloadDir, log, urlByAbsentLocal
6911
6973
  downloadDir,
6912
6974
  el.tagName,
6913
6975
  el.id,
6914
- log
6976
+ log,
6977
+ void 0,
6978
+ probes
6915
6979
  );
6916
6980
  return { id: el.id, tagName: el.tagName, duration: el.duration, maxDuration, src: el.src };
6917
6981
  })
@@ -6933,7 +6997,7 @@ async function compileHtmlFile(html, baseDir, downloadDir, log, urlByAbsentLocal
6933
6997
  compiledHtml = compiledHtml.replace(/(<audio\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
6934
6998
  return { html: compiledHtml, unresolvedCompositions };
6935
6999
  }
6936
- async function parseSubCompositions(html, projectDir, downloadDir, visited = /* @__PURE__ */ new Set(), urlByAbsentLocalPath) {
7000
+ async function parseSubCompositions(html, projectDir, downloadDir, visited = /* @__PURE__ */ new Set(), urlByAbsentLocalPath, probes) {
6937
7001
  const subCompositions = /* @__PURE__ */ new Map();
6938
7002
  const { document: document2 } = parseHTML5(html);
6939
7003
  const compEls = document2.querySelectorAll("[data-composition-src]");
@@ -6961,14 +7025,16 @@ async function parseSubCompositions(html, projectDir, downloadDir, visited = /*
6961
7025
  dirname7(item.filePath),
6962
7026
  downloadDir,
6963
7027
  void 0,
6964
- urlByAbsentLocalPath
7028
+ urlByAbsentLocalPath,
7029
+ probes
6965
7030
  );
6966
7031
  const nested = await parseSubCompositions(
6967
7032
  compiledSub,
6968
7033
  projectDir,
6969
7034
  downloadDir,
6970
7035
  item.nestedVisited,
6971
- urlByAbsentLocalPath
7036
+ urlByAbsentLocalPath,
7037
+ probes
6972
7038
  );
6973
7039
  return {
6974
7040
  srcPath: item.srcPath,
@@ -7738,19 +7804,22 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
7738
7804
  );
7739
7805
  const rawHtml = rewriteUnresolvableGsapToCdn(entryHtml, projectDir);
7740
7806
  assertSubCompositionsUsable(rawHtml, projectDir);
7807
+ const probes = /* @__PURE__ */ new Map();
7741
7808
  const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
7742
7809
  rawHtml,
7743
7810
  projectDir,
7744
7811
  downloadDir,
7745
7812
  options.log,
7746
- options.urlByAbsentLocalPath
7813
+ options.urlByAbsentLocalPath,
7814
+ probes
7747
7815
  );
7748
7816
  const { subCompositions } = await parseSubCompositions(
7749
7817
  compiledHtml,
7750
7818
  projectDir,
7751
7819
  downloadDir,
7752
7820
  void 0,
7753
- options.urlByAbsentLocalPath
7821
+ options.urlByAbsentLocalPath,
7822
+ probes
7754
7823
  );
7755
7824
  const fullHtml = ensureFullDocument(compiledHtml);
7756
7825
  const inlinedHtml = inlineSubCompositions(
@@ -7853,9 +7922,12 @@ async function compileForRender(projectDir, htmlPath, downloadDir, options = {})
7853
7922
  }
7854
7923
  const { videos, audios, images, droppedMediaIds } = collectRenderMedia(html);
7855
7924
  const masterFxChain = parseMasterFxChain(html);
7925
+ const advised = /* @__PURE__ */ new Set();
7856
7926
  for (const video of videos) {
7857
7927
  if (isHttpUrl(video.src)) continue;
7858
7928
  const videoPath = resolve7(projectDir, video.src);
7929
+ if (advised.has(videoPath)) continue;
7930
+ advised.add(videoPath);
7859
7931
  const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
7860
7932
  (async () => {
7861
7933
  const metadata = await withMediaProbeSlot(() => extractMediaMetadata(videoPath));
@@ -8406,13 +8478,14 @@ import {
8406
8478
  } from "@hyperframes/engine";
8407
8479
  import {
8408
8480
  MEDIA_RENDER_ID_ATTR as MEDIA_RENDER_ID_ATTR2,
8409
- fpsToNumber as fpsToNumber7,
8481
+ fpsToNumber as fpsToNumber6,
8410
8482
  normalizeRateSpec as normalizeRateSpec3,
8411
8483
  readMediaStart as readMediaStart2,
8412
8484
  shiftRateLane as shiftRateLane2,
8413
8485
  sourceTimeAt as sourceTimeAt3,
8414
8486
  timeAtSourceTime
8415
8487
  } from "@hyperframes/core";
8488
+ import { parseHostVariableValues as parseHostVariableValues2, readDeclaredDefaults as readDeclaredDefaults2 } from "@hyperframes/core/compiler";
8416
8489
  import { extractMediaSrcMutations } from "@hyperframes/parsers";
8417
8490
 
8418
8491
  // src/services/render/stages/probeFailures.ts
@@ -8450,8 +8523,15 @@ function hasAutoStartVideos(html) {
8450
8523
  const { document: document2 } = parseHTML6(html);
8451
8524
  return document2.querySelector("video[data-hf-auto-start]") !== null;
8452
8525
  }
8526
+ function variableHasValue(element, variableId, variables) {
8527
+ if (variables && Object.hasOwn(variables, variableId)) return true;
8528
+ for (let scope = element.parentElement; scope; scope = scope.parentElement) {
8529
+ if (Object.hasOwn(parseHostVariableValues2(scope), variableId)) return true;
8530
+ if (Object.hasOwn(readDeclaredDefaults2(scope), variableId)) return true;
8531
+ }
8532
+ return Object.hasOwn(readDeclaredDefaults2(element.ownerDocument.documentElement), variableId);
8533
+ }
8453
8534
  function hasVariableBoundMedia(html, variables) {
8454
- if (!variables || Object.keys(variables).length === 0) return false;
8455
8535
  const { document: document2 } = parseHTML6(html);
8456
8536
  return Array.from(
8457
8537
  document2.querySelectorAll(
@@ -8459,7 +8539,7 @@ function hasVariableBoundMedia(html, variables) {
8459
8539
  )
8460
8540
  ).some((element) => {
8461
8541
  const variableId = element.getAttribute("data-var-src")?.trim();
8462
- return Boolean(variableId && Object.hasOwn(variables, variableId));
8542
+ return Boolean(variableId && variableHasValue(element, variableId, variables));
8463
8543
  });
8464
8544
  }
8465
8545
  function authoredMediaStarts(html) {
@@ -8885,7 +8965,7 @@ async function runProbeStage(input) {
8885
8965
  session.page,
8886
8966
  composition.audios.map((audio) => audio.id),
8887
8967
  composition.duration,
8888
- fpsToNumber7(job.config.fps)
8968
+ fpsToNumber6(job.config.fps)
8889
8969
  );
8890
8970
  assertNotAborted();
8891
8971
  if (automation.length > 0) {
@@ -8948,7 +9028,7 @@ async function runProbeStage(input) {
8948
9028
  }
8949
9029
  const browserProbeMs = Date.now() - probeStart;
8950
9030
  const duration = composition.duration;
8951
- const totalFrames = durationToFrameCount(duration, fpsToNumber7(job.config.fps));
9031
+ const totalFrames = durationToFrameCount(duration, fpsToNumber6(job.config.fps));
8952
9032
  if (duration <= 0) {
8953
9033
  const diagnostics = [];
8954
9034
  try {
@@ -9448,16 +9528,17 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
9448
9528
 
9449
9529
  // src/services/render/stages/audioStage.ts
9450
9530
  import { join as join20 } from "node:path";
9451
- import { rmSync as rmSync10 } from "node:fs";
9452
9531
  import {
9453
9532
  MIXED_AUDIO_FILENAME,
9454
9533
  audioFailureDiagnostics,
9455
- processCompositionAudio
9534
+ processCompositionAudio,
9535
+ removeScratch as removeScratch2,
9536
+ collectScratchLeftovers
9456
9537
  } from "@hyperframes/engine";
9457
9538
 
9458
9539
  // src/services/render/audioPadTrim.ts
9459
9540
  import { spawn } from "node:child_process";
9460
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync9 } from "node:fs";
9541
+ import { mkdtempSync as mkdtempSync3 } from "node:fs";
9461
9542
  import { dirname as dirname8, join as join19 } from "node:path";
9462
9543
  import {
9463
9544
  DEFAULT_CONFIG,
@@ -9466,6 +9547,7 @@ import {
9466
9547
  extractAudioMetadata as extractAudioMetadata3,
9467
9548
  getFfprobeBinary,
9468
9549
  ManagedChildProcess,
9550
+ removeScratch,
9469
9551
  renameWithRetry as renameWithRetry5,
9470
9552
  runFfmpeg as runFfmpeg5,
9471
9553
  trackChildProcess
@@ -9661,7 +9743,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
9661
9743
  error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
9662
9744
  };
9663
9745
  } finally {
9664
- for (const path of plan.cleanupPaths) rmSync9(path, { force: true });
9746
+ for (const path of plan.cleanupPaths) removeScratch(path);
9665
9747
  }
9666
9748
  let loweredDb = 0;
9667
9749
  if (probeTruePeak) {
@@ -9724,7 +9806,7 @@ async function enforceAacTruePeak(input) {
9724
9806
  }
9725
9807
  if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
9726
9808
  if (measuredPath !== correctedPath) return { success: true };
9727
- renameWithRetry5(correctedPath, input.audioPath);
9809
+ renameWithRetry5(correctedPath, input.audioPath, { copyFallback: true });
9728
9810
  return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
9729
9811
  }
9730
9812
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
@@ -9765,7 +9847,7 @@ async function enforceAacTruePeak(input) {
9765
9847
  )}`
9766
9848
  };
9767
9849
  } finally {
9768
- if (scratchDir) rmSync9(scratchDir, { recursive: true, force: true });
9850
+ if (scratchDir) removeScratch(scratchDir);
9769
9851
  }
9770
9852
  }
9771
9853
  function failResult(outputPath, target, source, error) {
@@ -9910,7 +9992,15 @@ function assertAudioNotInterrupted(failures) {
9910
9992
  ])
9911
9993
  });
9912
9994
  }
9913
- async function runAudioStage(input) {
9995
+ function runAudioStage(input) {
9996
+ return collectScratchLeftovers(
9997
+ () => runAudioStageScoped(input),
9998
+ (left) => {
9999
+ if (left.length > 0) input.log?.warn("[audio] scratch left behind", { count: left.length });
10000
+ }
10001
+ );
10002
+ }
10003
+ async function runAudioStageScoped(input) {
9914
10004
  const stageStart = Date.now();
9915
10005
  const pcmSourcePath = join20(input.workDir, "audio-source.wav");
9916
10006
  const duration = input.totalFrames * input.fps.den / input.fps.num;
@@ -9932,7 +10022,7 @@ async function runAudioStage(input) {
9932
10022
  }
9933
10023
  return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
9934
10024
  } finally {
9935
- rmSync10(pcmSourcePath, { force: true });
10025
+ removeScratch2(pcmSourcePath);
9936
10026
  }
9937
10027
  }
9938
10028
  async function mixAudioForDelivery(input, duration, pcmSourcePath) {
@@ -10412,7 +10502,7 @@ import {
10412
10502
  initializeSession as initializeSession7,
10413
10503
  spawnStreamingEncoder as spawnStreamingEncoder3
10414
10504
  } from "@hyperframes/engine";
10415
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
10505
+ import { fpsToNumber as fpsToNumber7 } from "@hyperframes/core";
10416
10506
 
10417
10507
  // src/services/hdrImageTransferCache.ts
10418
10508
  import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
@@ -11086,6 +11176,7 @@ async function runCaptureHdrStage(input) {
11086
11176
  preset,
11087
11177
  effectiveQuality,
11088
11178
  effectiveBitrate,
11179
+ deliveryEncoder,
11089
11180
  fileServer,
11090
11181
  buildCaptureOptions,
11091
11182
  createRenderVideoFrameInjector,
@@ -11147,7 +11238,7 @@ async function runCaptureHdrStage(input) {
11147
11238
  }
11148
11239
  return map;
11149
11240
  });
11150
- const fpsDecimal = fpsToNumber8(job.config.fps);
11241
+ const fpsDecimal = fpsToNumber7(job.config.fps);
11151
11242
  const transitionRanges = transitionMeta.map((t) => ({
11152
11243
  ...t,
11153
11244
  startFrame: Math.floor(t.time * fpsDecimal),
@@ -11175,6 +11266,7 @@ async function runCaptureHdrStage(input) {
11175
11266
  preset: preset.preset,
11176
11267
  quality: effectiveQuality,
11177
11268
  bitrate: effectiveBitrate,
11269
+ ...deliveryEncoder,
11178
11270
  pixelFormat: preset.pixelFormat,
11179
11271
  hdr: preset.hdr,
11180
11272
  rawInputFormat: "rgb48le"
@@ -11234,7 +11326,7 @@ async function runCaptureHdrStage(input) {
11234
11326
  beforeCaptureHook: domSession.onBeforeCapture,
11235
11327
  width,
11236
11328
  height,
11237
- fps: fpsToNumber8(job.config.fps),
11329
+ fps: fpsToNumber7(job.config.fps),
11238
11330
  compositeTransfer,
11239
11331
  nativeHdrImageIds,
11240
11332
  hdrImageBuffers,
@@ -11378,7 +11470,7 @@ import {
11378
11470
  mkdirSync as mkdirSync9,
11379
11471
  readdirSync as readdirSync4,
11380
11472
  readFileSync as readFileSync8,
11381
- rmSync as rmSync11,
11473
+ rmSync as rmSync9,
11382
11474
  statSync as statSync7,
11383
11475
  writeFileSync as writeFileSync5
11384
11476
  } from "node:fs";
@@ -11517,7 +11609,7 @@ async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
11517
11609
  fileSize
11518
11610
  };
11519
11611
  } finally {
11520
- rmSync11(input.palettePath, { force: true });
11612
+ rmSync9(input.palettePath, { force: true });
11521
11613
  }
11522
11614
  }
11523
11615
  function startEncodeProgress(job, stage, onProgress) {
@@ -11541,6 +11633,8 @@ function videoEncoderOptions(input, engineCfg) {
11541
11633
  preset: preset.preset,
11542
11634
  quality: input.effectiveQuality,
11543
11635
  bitrate: input.effectiveBitrate,
11636
+ maxBitrate: input.maxBitrate,
11637
+ bufferSize: input.bufferSize,
11544
11638
  pixelFormat: preset.pixelFormat,
11545
11639
  vp9CpuUsed: engineCfg.vp9CpuUsed,
11546
11640
  useGpu: job.config.useGpu,
@@ -12815,6 +12909,7 @@ function resolveRenderDebugDir() {
12815
12909
  }
12816
12910
  async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
12817
12911
  validateHlsRenderConfig(job.config);
12912
+ validateDeliveryEncoderConfig(job.config);
12818
12913
  const debugDir = resolveRenderDebugDir();
12819
12914
  const outputDir = dirname11(outputPath);
12820
12915
  if (!existsSync16(outputDir)) mkdirSync10(outputDir, { recursive: true });
@@ -12835,7 +12930,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
12835
12930
  log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
12836
12931
  return;
12837
12932
  }
12838
- rmSync12(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
12933
+ rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
12839
12934
  });
12840
12935
  try {
12841
12936
  await executeRenderPipeline({
@@ -12893,11 +12988,14 @@ async function executeRenderPipeline(input) {
12893
12988
  const isGif = outputFormat === "gif";
12894
12989
  const isHls = outputFormat === "hls";
12895
12990
  const hlsSegmentSeconds = isHls ? resolveHlsSegmentSeconds(job.config) : void 0;
12896
- const hlsEncoderGopLock = resolveHlsEncoderGopLock(
12897
- outputFormat,
12898
- job.config.fps,
12899
- job.config.hlsSegmentSeconds
12900
- );
12991
+ const deliveryEncoder = resolveDeliveryEncoderOptions({
12992
+ format: outputFormat,
12993
+ fps: job.config.fps,
12994
+ hlsSegmentSeconds: job.config.hlsSegmentSeconds,
12995
+ keyframeIntervalSeconds: job.config.keyframeIntervalSeconds,
12996
+ maxBitrate: job.config.maxBitrate,
12997
+ bufferSize: job.config.bufferSize
12998
+ });
12901
12999
  const artifactTransaction = new ArtifactTransaction(
12902
13000
  outputPath,
12903
13001
  isPngSequence || isHls ? "directory" : "file",
@@ -13137,7 +13235,7 @@ async function executeRenderPipeline(input) {
13137
13235
  validateRenderDuration({
13138
13236
  duration: probeResult.duration,
13139
13237
  totalFrames,
13140
- fps: fpsToNumber9(job.config.fps)
13238
+ fps: fpsToNumber8(job.config.fps)
13141
13239
  });
13142
13240
  perfStages.browserProbeMs = probeResult.browserProbeMs;
13143
13241
  perfStages.compileMs = Date.now() - stage1Start;
@@ -13980,6 +14078,7 @@ async function executeRenderPipeline(input) {
13980
14078
  preset,
13981
14079
  effectiveQuality,
13982
14080
  effectiveBitrate,
14081
+ deliveryEncoder,
13983
14082
  fileServer: activeFileServer,
13984
14083
  buildCaptureOptions,
13985
14084
  createRenderVideoFrameInjector,
@@ -14004,9 +14103,7 @@ async function executeRenderPipeline(input) {
14004
14103
  } else {
14005
14104
  let streamingHandled = false;
14006
14105
  if (capturePlan.kind === "sdr_segmented") {
14007
- const segmentedPlan = capturePlan;
14008
14106
  const captureFrameStart = Date.now();
14009
- resetCaptureAttemptProgress(job);
14010
14107
  const segmentFrames = resolveSegmentFrames(process.env);
14011
14108
  const segmentImageFormat = captureOptions.format || "jpeg";
14012
14109
  const segmentPlanHash = computeSegmentPlanHash({
@@ -14021,6 +14118,9 @@ async function executeRenderPipeline(input) {
14021
14118
  preset: preset.preset,
14022
14119
  quality: effectiveQuality,
14023
14120
  bitrate: effectiveBitrate,
14121
+ maxBitrate: deliveryEncoder.maxBitrate,
14122
+ bufferSize: deliveryEncoder.bufferSize,
14123
+ gopSize: deliveryEncoder.gopSize,
14024
14124
  pixelFormat: preset.pixelFormat,
14025
14125
  imageFormat: segmentImageFormat,
14026
14126
  useGpu: job.config.useGpu === true,
@@ -14046,71 +14146,129 @@ async function executeRenderPipeline(input) {
14046
14146
  });
14047
14147
  }
14048
14148
  if (completedSegments.size === 0) {
14049
- rmSync12(segmentDir, { recursive: true, force: true });
14149
+ rmSync10(segmentDir, { recursive: true, force: true });
14050
14150
  }
14051
14151
  } else {
14052
- rmSync12(segmentDir, { recursive: true, force: true });
14152
+ rmSync10(segmentDir, { recursive: true, force: true });
14053
14153
  }
14054
- const segmentManifest = readSegmentManifest(segmentDir) ?? {
14154
+ const emptySegmentManifest = () => ({
14055
14155
  version: 1,
14056
14156
  planHash: segmentPlanHash,
14057
14157
  totalFrames,
14058
14158
  segmentFrames,
14059
14159
  completed: []
14160
+ });
14161
+ let segmentManifest = readSegmentManifest(segmentDir) ?? emptySegmentManifest();
14162
+ const invokeSegmented = () => {
14163
+ if (capturePlan.kind !== "sdr_segmented") {
14164
+ throw new Error(`Cannot invoke segmented stage with ${capturePlan.kind} plan`);
14165
+ }
14166
+ const segmentedPlan = capturePlan;
14167
+ resetCaptureAttemptProgress(job);
14168
+ return observeRenderStage(
14169
+ observability,
14170
+ "capture_segmented",
14171
+ captureStageObservationData(),
14172
+ () => runCaptureSegmentedStage({
14173
+ fileServer: activeFileServer,
14174
+ workDir,
14175
+ framesDir,
14176
+ videoOnlyPath,
14177
+ job,
14178
+ totalFrames,
14179
+ cfg,
14180
+ plan: segmentedPlan,
14181
+ log,
14182
+ probeSession,
14183
+ outputFormat,
14184
+ streamingEncoderOptions: {
14185
+ fps: job.config.fps,
14186
+ width: captureCompositionWidth ?? width,
14187
+ height: captureCompositionHeight ?? height,
14188
+ codec: preset.codec,
14189
+ preset: preset.preset,
14190
+ quality: effectiveQuality,
14191
+ bitrate: effectiveBitrate,
14192
+ pixelFormat: preset.pixelFormat,
14193
+ vp9CpuUsed: cfg.vp9CpuUsed,
14194
+ useGpu: job.config.useGpu,
14195
+ imageFormat: segmentImageFormat,
14196
+ hdr: preset.hdr,
14197
+ // Each segment sets its own GOP (at most the interval); hls never reaches this route.
14198
+ ...deliveryEncoder
14199
+ },
14200
+ buildCaptureOptions,
14201
+ createRenderVideoFrameInjector,
14202
+ abortSignal: executionSignal,
14203
+ assertNotAborted,
14204
+ onProgress,
14205
+ dedupPerfs,
14206
+ segmentFrames,
14207
+ segmentDir,
14208
+ workerCount: segmentedPlan.workerCount,
14209
+ browserRecycleEverySegments: resolveSegmentBrowserRecycle(process.env),
14210
+ completedSegments,
14211
+ onSegmentComplete: (entry) => {
14212
+ segmentManifest.completed = [
14213
+ ...segmentManifest.completed.filter((e) => e.index !== entry.index),
14214
+ { ...entry, completedAt: (/* @__PURE__ */ new Date()).toISOString() }
14215
+ ];
14216
+ writeSegmentManifest(segmentDir, segmentManifest);
14217
+ },
14218
+ updateCaptureObservability
14219
+ })
14220
+ );
14060
14221
  };
14061
- const segmentedRes = await observeRenderStage(
14062
- observability,
14063
- "capture_segmented",
14064
- captureStageObservationData(),
14065
- () => runCaptureSegmentedStage({
14066
- fileServer: activeFileServer,
14067
- workDir,
14068
- framesDir,
14069
- videoOnlyPath,
14070
- job,
14071
- totalFrames,
14072
- cfg,
14073
- plan: segmentedPlan,
14074
- log,
14075
- probeSession,
14076
- outputFormat,
14077
- streamingEncoderOptions: {
14078
- fps: job.config.fps,
14079
- width: captureCompositionWidth ?? width,
14080
- height: captureCompositionHeight ?? height,
14081
- codec: preset.codec,
14082
- preset: preset.preset,
14083
- quality: effectiveQuality,
14084
- bitrate: effectiveBitrate,
14085
- pixelFormat: preset.pixelFormat,
14086
- vp9CpuUsed: cfg.vp9CpuUsed,
14087
- useGpu: job.config.useGpu,
14088
- imageFormat: segmentImageFormat,
14089
- hdr: preset.hdr
14090
- // No hlsEncoderGopLock here: each segment sets its own GOP to
14091
- // its own length, and hls never reaches this route.
14092
- },
14093
- buildCaptureOptions,
14094
- createRenderVideoFrameInjector,
14095
- abortSignal: executionSignal,
14096
- assertNotAborted,
14097
- onProgress,
14098
- dedupPerfs,
14099
- segmentFrames,
14100
- segmentDir,
14101
- workerCount: segmentedPlan.workerCount,
14102
- browserRecycleEverySegments: resolveSegmentBrowserRecycle(process.env),
14103
- completedSegments,
14104
- onSegmentComplete: (entry) => {
14105
- segmentManifest.completed = [
14106
- ...segmentManifest.completed.filter((e) => e.index !== entry.index),
14107
- { ...entry, completedAt: (/* @__PURE__ */ new Date()).toISOString() }
14108
- ];
14109
- writeSegmentManifest(segmentDir, segmentManifest);
14110
- },
14111
- updateCaptureObservability
14112
- })
14113
- );
14222
+ let segmentedRes;
14223
+ try {
14224
+ segmentedRes = await invokeSegmented();
14225
+ } catch (err) {
14226
+ const isVerifyError = isDrawElementVerificationError(err);
14227
+ const isDeStall = isDeRendererStallError(err);
14228
+ if (!isVerifyError && !isDrawElementCaptureError(err) && !isDeStall || err instanceof RenderCancelledError || executionSignal?.aborted === true) {
14229
+ throw err;
14230
+ }
14231
+ deSelfVerifyFallback = isVerifyError;
14232
+ const t = deVerifyFallbackTelemetry(err);
14233
+ deFallbackReason = isVerifyError ? t.reason : isDeStall ? "de_renderer_stall" : "capture_error";
14234
+ deFallbackFailedDb = t.failedDb;
14235
+ deFallbackFrameIndex = t.frameIndex;
14236
+ deFallbackThresholdDb = t.thresholdDb;
14237
+ log.warn(
14238
+ "[Render] drawElement capture failed on the segmented path; re-rendering via screenshot",
14239
+ {
14240
+ error: err instanceof Error ? err.message : String(err)
14241
+ }
14242
+ );
14243
+ observability.checkpoint(
14244
+ "capture_segmented",
14245
+ "drawElement capture failed; retrying with a fresh screenshot session"
14246
+ );
14247
+ if (probeSession) {
14248
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
14249
+ const orphaned = probeSession;
14250
+ probeSession = null;
14251
+ await closeOrphanedProbeForRetry(orphaned, closeCaptureSession8, log, "segmented");
14252
+ }
14253
+ rmSync10(segmentDir, { recursive: true, force: true });
14254
+ completedSegments = /* @__PURE__ */ new Set();
14255
+ segmentManifest = emptySegmentManifest();
14256
+ dedupPerfs.length = 0;
14257
+ capturePlan = replanAfterFailure(capturePlan, {
14258
+ kind: isVerifyError ? "draw_element_verification" : "draw_element_capture"
14259
+ });
14260
+ syncCapturePlan();
14261
+ updateCaptureObservability({
14262
+ forceScreenshot: capturePlan.forceScreenshot,
14263
+ deSelfVerifyFallback,
14264
+ deFallbackReason,
14265
+ deFallbackFailedDb,
14266
+ deFallbackFrameIndex,
14267
+ deFallbackThresholdDb
14268
+ });
14269
+ segmentedRes = await invokeSegmented();
14270
+ observability.clearFailure("capture_segmented");
14271
+ }
14114
14272
  if (segmentedRes.success) {
14115
14273
  streamingHandled = true;
14116
14274
  workerCount = segmentedRes.workerCount;
@@ -14130,7 +14288,7 @@ async function executeRenderPipeline(input) {
14130
14288
  );
14131
14289
  updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
14132
14290
  if (job.config.keepSegments !== true) {
14133
- rmSync12(segmentDir, { recursive: true, force: true });
14291
+ rmSync10(segmentDir, { recursive: true, force: true });
14134
14292
  }
14135
14293
  } else {
14136
14294
  capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
@@ -14183,7 +14341,7 @@ async function executeRenderPipeline(input) {
14183
14341
  // assemble stage's `-c copy` segmentation cuts exactly on
14184
14342
  // the segment boundary. Every other format leaves the
14185
14343
  // encoder's open-GOP output untouched.
14186
- ...hlsEncoderGopLock
14344
+ ...deliveryEncoder
14187
14345
  },
14188
14346
  buildCaptureOptions,
14189
14347
  createRenderVideoFrameInjector,
@@ -14346,7 +14504,7 @@ async function executeRenderPipeline(input) {
14346
14504
  engineConfig: cfg,
14347
14505
  // Same value the streaming encoder above spreads — the disk and
14348
14506
  // chunked-concat encoders need an identical GOP lock for HLS.
14349
- ...hlsEncoderGopLock,
14507
+ ...deliveryEncoder,
14350
14508
  abortSignal: executionSignal,
14351
14509
  assertNotAborted,
14352
14510
  onProgress
@@ -14402,7 +14560,7 @@ async function executeRenderPipeline(input) {
14402
14560
  "capture_disk",
14403
14561
  "drawElement capture failed; retrying with a fresh screenshot session"
14404
14562
  );
14405
- rmSync12(framesDir, { recursive: true, force: true });
14563
+ rmSync10(framesDir, { recursive: true, force: true });
14406
14564
  mkdirSync10(framesDir, { recursive: true });
14407
14565
  resetCaptureAttemptProgress(job);
14408
14566
  dedupPerfs.length = 0;
@@ -14500,7 +14658,7 @@ async function executeRenderPipeline(input) {
14500
14658
  buildArtifactExpectation({
14501
14659
  outputFormat,
14502
14660
  durationSeconds: job.duration,
14503
- fps: fpsToNumber9(job.config.fps),
14661
+ fps: fpsToNumber8(job.config.fps),
14504
14662
  expectedFrames: captureTotalFrames,
14505
14663
  width: outputWidth,
14506
14664
  height: outputHeight
@@ -14930,11 +15088,12 @@ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
14930
15088
  import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
14931
15089
  import { extname as extname4, isAbsolute as isAbsolute4, join as join30, relative as relative4, sep as sep3 } from "node:path";
14932
15090
  import {
14933
- fpsToNumber as fpsToNumber10,
15091
+ fpsToNumber as fpsToNumber9,
14934
15092
  HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
14935
15093
  } from "@hyperframes/core";
14936
15094
  import {
14937
15095
  getEncoderPreset as getEncoderPreset4,
15096
+ keyframeIntervalToGopSize as keyframeIntervalToGopSize3,
14938
15097
  normalizeVp9CpuUsed,
14939
15098
  resolveConfig as resolveConfig3,
14940
15099
  declareContentAddressedUrl,
@@ -15130,6 +15289,12 @@ function assertOptionalInteger(options, field, min = 0) {
15130
15289
  throw new Error(`Render request ${field} must be an integer >= ${min}`);
15131
15290
  }
15132
15291
  }
15292
+ function assertOptionalPositiveNumber(options, field) {
15293
+ const value = options[field];
15294
+ if (value !== void 0 && !(typeof value === "number" && value > 0)) {
15295
+ throw new Error(`Render request ${field} must be a positive number`);
15296
+ }
15297
+ }
15133
15298
  function assertOptionalString(options, field) {
15134
15299
  if (options[field] !== void 0 && typeof options[field] !== "string") {
15135
15300
  throw new Error(`Render request ${field} must be a string`);
@@ -15177,10 +15342,11 @@ function assertRequestOptionScalars(options) {
15177
15342
  assertOptionalInteger(options, "workers", 1);
15178
15343
  assertOptionalInteger(options, "maxRenderMs");
15179
15344
  assertOptionalInteger(options, "crf");
15345
+ assertOptionalPositiveNumber(options, "keyframeIntervalSeconds");
15180
15346
  for (const field of ["useGpu", "debug", "outputResolutionAspectAgnostic"]) {
15181
15347
  assertOptionalBoolean(options, field);
15182
15348
  }
15183
- for (const field of ["entryFile", "videoBitrate"]) {
15349
+ for (const field of ["entryFile", "videoBitrate", "maxBitrate", "bufferSize"]) {
15184
15350
  assertOptionalString(options, field);
15185
15351
  }
15186
15352
  assertOptionalEnum(options, "strictness", ["strict", "best-effort"]);
@@ -15341,7 +15507,7 @@ function parseRenderOptions(body) {
15341
15507
  const format = parseServerFormat(body.format);
15342
15508
  const outputDynamicRange = parseServerOutputDynamicRange(body.outputDynamicRange) ?? fromRenderHdrMode(parseLegacyServerHdrMode(body.hdrMode));
15343
15509
  const encoder = parseEncoderOptions(body, format ?? "mp4");
15344
- const { crf, videoBitrate, videoFrameFormat } = encoder.ok ? encoder.options : {};
15510
+ const { crf, videoBitrate, videoFrameFormat, keyframeIntervalSeconds, maxBitrate, bufferSize } = encoder.ok ? encoder.options : {};
15345
15511
  const { variables, outputResolution, outputResolutionAspectAgnostic } = parseRenderOverrides(body);
15346
15512
  return {
15347
15513
  outputPath,
@@ -15351,6 +15517,9 @@ function parseRenderOptions(body) {
15351
15517
  maxRenderMs,
15352
15518
  crf,
15353
15519
  videoBitrate,
15520
+ keyframeIntervalSeconds,
15521
+ maxBitrate,
15522
+ bufferSize,
15354
15523
  useGpu,
15355
15524
  debug,
15356
15525
  strictness,
@@ -15385,6 +15554,9 @@ function buildRenderJobConfig(input, outputPath, log) {
15385
15554
  maxRenderMs: input.maxRenderMs,
15386
15555
  crf: input.crf,
15387
15556
  videoBitrate: input.videoBitrate,
15557
+ keyframeIntervalSeconds: input.keyframeIntervalSeconds,
15558
+ maxBitrate: input.maxBitrate,
15559
+ bufferSize: input.bufferSize,
15388
15560
  useGpu: input.useGpu,
15389
15561
  debug: input.debug,
15390
15562
  strictness: input.strictness,
@@ -15531,7 +15703,7 @@ function createArtifactStore(ttlMs) {
15531
15703
  function cleanupTempDir(dir, log) {
15532
15704
  if (!dir) return;
15533
15705
  try {
15534
- rmSync13(dir, { recursive: true, force: true });
15706
+ rmSync11(dir, { recursive: true, force: true });
15535
15707
  } catch (error) {
15536
15708
  log.warn("Failed to cleanup temp project dir", {
15537
15709
  cleanupProjectDir: dir,