@hyperframes/producer 0.8.206 → 0.8.208

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 {
@@ -9162,10 +9242,11 @@ var VideoExtractionStageError = class extends Error {
9162
9242
  kind: failure.kind,
9163
9243
  affectedElementCount: failure.count
9164
9244
  }))
9165
- )) {
9245
+ ), ffmpegReason) {
9166
9246
  const total = failures.reduce((sum, failure) => sum + failure.count, 0);
9167
9247
  const breakdown = failures.map((failure) => `${failure.kind}=${failure.count}`).join(",");
9168
- super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]`);
9248
+ const reason = ffmpegReason ? `: ffmpeg said: ${ffmpegReason}` : "";
9249
+ super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]${reason}`);
9169
9250
  this.code = code;
9170
9251
  this.retryable = retryable;
9171
9252
  this.failures = failures;
@@ -9207,7 +9288,8 @@ function buildVideoExtractionStageError(result) {
9207
9288
  retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
9208
9289
  retryable,
9209
9290
  failures,
9210
- extractionFailureMetadataFromResult(result)
9291
+ extractionFailureMetadataFromResult(result),
9292
+ result.errors.find((failure) => failure.ffmpegReason)?.ffmpegReason
9211
9293
  );
9212
9294
  }
9213
9295
  function buildHdrProbeStageError(failures) {
@@ -9448,16 +9530,17 @@ function appendAutoDetectedVideoAudio(composition, extracted) {
9448
9530
 
9449
9531
  // src/services/render/stages/audioStage.ts
9450
9532
  import { join as join20 } from "node:path";
9451
- import { rmSync as rmSync10 } from "node:fs";
9452
9533
  import {
9453
9534
  MIXED_AUDIO_FILENAME,
9454
9535
  audioFailureDiagnostics,
9455
- processCompositionAudio
9536
+ processCompositionAudio,
9537
+ removeScratch as removeScratch2,
9538
+ collectScratchLeftovers
9456
9539
  } from "@hyperframes/engine";
9457
9540
 
9458
9541
  // src/services/render/audioPadTrim.ts
9459
9542
  import { spawn } from "node:child_process";
9460
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync9 } from "node:fs";
9543
+ import { mkdtempSync as mkdtempSync3 } from "node:fs";
9461
9544
  import { dirname as dirname8, join as join19 } from "node:path";
9462
9545
  import {
9463
9546
  DEFAULT_CONFIG,
@@ -9466,6 +9549,7 @@ import {
9466
9549
  extractAudioMetadata as extractAudioMetadata3,
9467
9550
  getFfprobeBinary,
9468
9551
  ManagedChildProcess,
9552
+ removeScratch,
9469
9553
  renameWithRetry as renameWithRetry5,
9470
9554
  runFfmpeg as runFfmpeg5,
9471
9555
  trackChildProcess
@@ -9661,7 +9745,7 @@ async function padOrTrimAudioToVideoFrameCount(input) {
9661
9745
  error: `audioPadTrim: failed to materialize ${plan.operation}: ${err instanceof Error ? err.message : String(err)}`
9662
9746
  };
9663
9747
  } finally {
9664
- for (const path of plan.cleanupPaths) rmSync9(path, { force: true });
9748
+ for (const path of plan.cleanupPaths) removeScratch(path);
9665
9749
  }
9666
9750
  let loweredDb = 0;
9667
9751
  if (probeTruePeak) {
@@ -9724,7 +9808,7 @@ async function enforceAacTruePeak(input) {
9724
9808
  }
9725
9809
  if (truePeakDbfs <= AAC_DELIVERY_TRUE_PEAK_DBFS) {
9726
9810
  if (measuredPath !== correctedPath) return { success: true };
9727
- renameWithRetry5(correctedPath, input.audioPath);
9811
+ renameWithRetry5(correctedPath, input.audioPath, { copyFallback: true });
9728
9812
  return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
9729
9813
  }
9730
9814
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
@@ -9765,7 +9849,7 @@ async function enforceAacTruePeak(input) {
9765
9849
  )}`
9766
9850
  };
9767
9851
  } finally {
9768
- if (scratchDir) rmSync9(scratchDir, { recursive: true, force: true });
9852
+ if (scratchDir) removeScratch(scratchDir);
9769
9853
  }
9770
9854
  }
9771
9855
  function failResult(outputPath, target, source, error) {
@@ -9910,7 +9994,15 @@ function assertAudioNotInterrupted(failures) {
9910
9994
  ])
9911
9995
  });
9912
9996
  }
9913
- async function runAudioStage(input) {
9997
+ function runAudioStage(input) {
9998
+ return collectScratchLeftovers(
9999
+ () => runAudioStageScoped(input),
10000
+ (left) => {
10001
+ if (left.length > 0) input.log?.warn("[audio] scratch left behind", { count: left.length });
10002
+ }
10003
+ );
10004
+ }
10005
+ async function runAudioStageScoped(input) {
9914
10006
  const stageStart = Date.now();
9915
10007
  const pcmSourcePath = join20(input.workDir, "audio-source.wav");
9916
10008
  const duration = input.totalFrames * input.fps.den / input.fps.num;
@@ -9932,7 +10024,7 @@ async function runAudioStage(input) {
9932
10024
  }
9933
10025
  return { ...result, audioLoweredDb, audioProcessMs: Date.now() - stageStart };
9934
10026
  } finally {
9935
- rmSync10(pcmSourcePath, { force: true });
10027
+ removeScratch2(pcmSourcePath);
9936
10028
  }
9937
10029
  }
9938
10030
  async function mixAudioForDelivery(input, duration, pcmSourcePath) {
@@ -10194,6 +10286,7 @@ async function runCaptureStage(input) {
10194
10286
  dedupPerfs,
10195
10287
  pageErrorLines: workerPageErrors,
10196
10288
  openAttempt: input.openAttempt,
10289
+ verifyFirstSession: input.verifyFirstSession,
10197
10290
  onProgress: (progress) => {
10198
10291
  if (progress.latestWorkerPhase) {
10199
10292
  if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
@@ -10412,7 +10505,7 @@ import {
10412
10505
  initializeSession as initializeSession7,
10413
10506
  spawnStreamingEncoder as spawnStreamingEncoder3
10414
10507
  } from "@hyperframes/engine";
10415
- import { fpsToNumber as fpsToNumber8 } from "@hyperframes/core";
10508
+ import { fpsToNumber as fpsToNumber7 } from "@hyperframes/core";
10416
10509
 
10417
10510
  // src/services/hdrImageTransferCache.ts
10418
10511
  import { convertTransfer as convertTransfer2 } from "@hyperframes/engine";
@@ -11086,6 +11179,7 @@ async function runCaptureHdrStage(input) {
11086
11179
  preset,
11087
11180
  effectiveQuality,
11088
11181
  effectiveBitrate,
11182
+ deliveryEncoder,
11089
11183
  fileServer,
11090
11184
  buildCaptureOptions,
11091
11185
  createRenderVideoFrameInjector,
@@ -11147,7 +11241,7 @@ async function runCaptureHdrStage(input) {
11147
11241
  }
11148
11242
  return map;
11149
11243
  });
11150
- const fpsDecimal = fpsToNumber8(job.config.fps);
11244
+ const fpsDecimal = fpsToNumber7(job.config.fps);
11151
11245
  const transitionRanges = transitionMeta.map((t) => ({
11152
11246
  ...t,
11153
11247
  startFrame: Math.floor(t.time * fpsDecimal),
@@ -11175,6 +11269,7 @@ async function runCaptureHdrStage(input) {
11175
11269
  preset: preset.preset,
11176
11270
  quality: effectiveQuality,
11177
11271
  bitrate: effectiveBitrate,
11272
+ ...deliveryEncoder,
11178
11273
  pixelFormat: preset.pixelFormat,
11179
11274
  hdr: preset.hdr,
11180
11275
  rawInputFormat: "rgb48le"
@@ -11234,7 +11329,7 @@ async function runCaptureHdrStage(input) {
11234
11329
  beforeCaptureHook: domSession.onBeforeCapture,
11235
11330
  width,
11236
11331
  height,
11237
- fps: fpsToNumber8(job.config.fps),
11332
+ fps: fpsToNumber7(job.config.fps),
11238
11333
  compositeTransfer,
11239
11334
  nativeHdrImageIds,
11240
11335
  hdrImageBuffers,
@@ -11378,7 +11473,7 @@ import {
11378
11473
  mkdirSync as mkdirSync9,
11379
11474
  readdirSync as readdirSync4,
11380
11475
  readFileSync as readFileSync8,
11381
- rmSync as rmSync11,
11476
+ rmSync as rmSync9,
11382
11477
  statSync as statSync7,
11383
11478
  writeFileSync as writeFileSync5
11384
11479
  } from "node:fs";
@@ -11517,7 +11612,7 @@ async function encodeGifFromDir(framesDir, framePattern, outputPath, input) {
11517
11612
  fileSize
11518
11613
  };
11519
11614
  } finally {
11520
- rmSync11(input.palettePath, { force: true });
11615
+ rmSync9(input.palettePath, { force: true });
11521
11616
  }
11522
11617
  }
11523
11618
  function startEncodeProgress(job, stage, onProgress) {
@@ -11541,6 +11636,8 @@ function videoEncoderOptions(input, engineCfg) {
11541
11636
  preset: preset.preset,
11542
11637
  quality: input.effectiveQuality,
11543
11638
  bitrate: input.effectiveBitrate,
11639
+ maxBitrate: input.maxBitrate,
11640
+ bufferSize: input.bufferSize,
11544
11641
  pixelFormat: preset.pixelFormat,
11545
11642
  vp9CpuUsed: engineCfg.vp9CpuUsed,
11546
11643
  useGpu: job.config.useGpu,
@@ -12249,7 +12346,8 @@ async function executeDiskCaptureWithAdaptiveRetry(options) {
12249
12346
  });
12250
12347
  } : void 0,
12251
12348
  void 0,
12252
- options.cfg
12349
+ options.cfg,
12350
+ { verifyFirstSession: options.verifyFirstSession }
12253
12351
  );
12254
12352
  pushWorkerDedupPerfs(workerResults, options.dedupPerfs);
12255
12353
  if (options.pageErrorLines) pushWorkerPageErrors(workerResults, options.pageErrorLines);
@@ -12815,6 +12913,7 @@ function resolveRenderDebugDir() {
12815
12913
  }
12816
12914
  async function executeRenderJob(job, projectDir, outputPath, progressSink, abortSignal, assertRenderActive) {
12817
12915
  validateHlsRenderConfig(job.config);
12916
+ validateDeliveryEncoderConfig(job.config);
12818
12917
  const debugDir = resolveRenderDebugDir();
12819
12918
  const outputDir = dirname11(outputPath);
12820
12919
  if (!existsSync16(outputDir)) mkdirSync10(outputDir, { recursive: true });
@@ -12835,7 +12934,7 @@ async function executeRenderJob(job, projectDir, outputPath, progressSink, abort
12835
12934
  log.info("KEEP_TEMP=1, leaving workDir on disk for inspection", { workDir });
12836
12935
  return;
12837
12936
  }
12838
- rmSync12(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
12937
+ rmSync10(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
12839
12938
  });
12840
12939
  try {
12841
12940
  await executeRenderPipeline({
@@ -12893,11 +12992,14 @@ async function executeRenderPipeline(input) {
12893
12992
  const isGif = outputFormat === "gif";
12894
12993
  const isHls = outputFormat === "hls";
12895
12994
  const hlsSegmentSeconds = isHls ? resolveHlsSegmentSeconds(job.config) : void 0;
12896
- const hlsEncoderGopLock = resolveHlsEncoderGopLock(
12897
- outputFormat,
12898
- job.config.fps,
12899
- job.config.hlsSegmentSeconds
12900
- );
12995
+ const deliveryEncoder = resolveDeliveryEncoderOptions({
12996
+ format: outputFormat,
12997
+ fps: job.config.fps,
12998
+ hlsSegmentSeconds: job.config.hlsSegmentSeconds,
12999
+ keyframeIntervalSeconds: job.config.keyframeIntervalSeconds,
13000
+ maxBitrate: job.config.maxBitrate,
13001
+ bufferSize: job.config.bufferSize
13002
+ });
12901
13003
  const artifactTransaction = new ArtifactTransaction(
12902
13004
  outputPath,
12903
13005
  isPngSequence || isHls ? "directory" : "file",
@@ -13137,7 +13239,7 @@ async function executeRenderPipeline(input) {
13137
13239
  validateRenderDuration({
13138
13240
  duration: probeResult.duration,
13139
13241
  totalFrames,
13140
- fps: fpsToNumber9(job.config.fps)
13242
+ fps: fpsToNumber8(job.config.fps)
13141
13243
  });
13142
13244
  perfStages.browserProbeMs = probeResult.browserProbeMs;
13143
13245
  perfStages.compileMs = Date.now() - stage1Start;
@@ -13980,6 +14082,7 @@ async function executeRenderPipeline(input) {
13980
14082
  preset,
13981
14083
  effectiveQuality,
13982
14084
  effectiveBitrate,
14085
+ deliveryEncoder,
13983
14086
  fileServer: activeFileServer,
13984
14087
  buildCaptureOptions,
13985
14088
  createRenderVideoFrameInjector,
@@ -14004,9 +14107,7 @@ async function executeRenderPipeline(input) {
14004
14107
  } else {
14005
14108
  let streamingHandled = false;
14006
14109
  if (capturePlan.kind === "sdr_segmented") {
14007
- const segmentedPlan = capturePlan;
14008
14110
  const captureFrameStart = Date.now();
14009
- resetCaptureAttemptProgress(job);
14010
14111
  const segmentFrames = resolveSegmentFrames(process.env);
14011
14112
  const segmentImageFormat = captureOptions.format || "jpeg";
14012
14113
  const segmentPlanHash = computeSegmentPlanHash({
@@ -14021,6 +14122,9 @@ async function executeRenderPipeline(input) {
14021
14122
  preset: preset.preset,
14022
14123
  quality: effectiveQuality,
14023
14124
  bitrate: effectiveBitrate,
14125
+ maxBitrate: deliveryEncoder.maxBitrate,
14126
+ bufferSize: deliveryEncoder.bufferSize,
14127
+ gopSize: deliveryEncoder.gopSize,
14024
14128
  pixelFormat: preset.pixelFormat,
14025
14129
  imageFormat: segmentImageFormat,
14026
14130
  useGpu: job.config.useGpu === true,
@@ -14046,71 +14150,129 @@ async function executeRenderPipeline(input) {
14046
14150
  });
14047
14151
  }
14048
14152
  if (completedSegments.size === 0) {
14049
- rmSync12(segmentDir, { recursive: true, force: true });
14153
+ rmSync10(segmentDir, { recursive: true, force: true });
14050
14154
  }
14051
14155
  } else {
14052
- rmSync12(segmentDir, { recursive: true, force: true });
14156
+ rmSync10(segmentDir, { recursive: true, force: true });
14053
14157
  }
14054
- const segmentManifest = readSegmentManifest(segmentDir) ?? {
14158
+ const emptySegmentManifest = () => ({
14055
14159
  version: 1,
14056
14160
  planHash: segmentPlanHash,
14057
14161
  totalFrames,
14058
14162
  segmentFrames,
14059
14163
  completed: []
14164
+ });
14165
+ let segmentManifest = readSegmentManifest(segmentDir) ?? emptySegmentManifest();
14166
+ const invokeSegmented = () => {
14167
+ if (capturePlan.kind !== "sdr_segmented") {
14168
+ throw new Error(`Cannot invoke segmented stage with ${capturePlan.kind} plan`);
14169
+ }
14170
+ const segmentedPlan = capturePlan;
14171
+ resetCaptureAttemptProgress(job);
14172
+ return observeRenderStage(
14173
+ observability,
14174
+ "capture_segmented",
14175
+ captureStageObservationData(),
14176
+ () => runCaptureSegmentedStage({
14177
+ fileServer: activeFileServer,
14178
+ workDir,
14179
+ framesDir,
14180
+ videoOnlyPath,
14181
+ job,
14182
+ totalFrames,
14183
+ cfg,
14184
+ plan: segmentedPlan,
14185
+ log,
14186
+ probeSession,
14187
+ outputFormat,
14188
+ streamingEncoderOptions: {
14189
+ fps: job.config.fps,
14190
+ width: captureCompositionWidth ?? width,
14191
+ height: captureCompositionHeight ?? height,
14192
+ codec: preset.codec,
14193
+ preset: preset.preset,
14194
+ quality: effectiveQuality,
14195
+ bitrate: effectiveBitrate,
14196
+ pixelFormat: preset.pixelFormat,
14197
+ vp9CpuUsed: cfg.vp9CpuUsed,
14198
+ useGpu: job.config.useGpu,
14199
+ imageFormat: segmentImageFormat,
14200
+ hdr: preset.hdr,
14201
+ // Each segment sets its own GOP (at most the interval); hls never reaches this route.
14202
+ ...deliveryEncoder
14203
+ },
14204
+ buildCaptureOptions,
14205
+ createRenderVideoFrameInjector,
14206
+ abortSignal: executionSignal,
14207
+ assertNotAborted,
14208
+ onProgress,
14209
+ dedupPerfs,
14210
+ segmentFrames,
14211
+ segmentDir,
14212
+ workerCount: segmentedPlan.workerCount,
14213
+ browserRecycleEverySegments: resolveSegmentBrowserRecycle(process.env),
14214
+ completedSegments,
14215
+ onSegmentComplete: (entry) => {
14216
+ segmentManifest.completed = [
14217
+ ...segmentManifest.completed.filter((e) => e.index !== entry.index),
14218
+ { ...entry, completedAt: (/* @__PURE__ */ new Date()).toISOString() }
14219
+ ];
14220
+ writeSegmentManifest(segmentDir, segmentManifest);
14221
+ },
14222
+ updateCaptureObservability
14223
+ })
14224
+ );
14060
14225
  };
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
- );
14226
+ let segmentedRes;
14227
+ try {
14228
+ segmentedRes = await invokeSegmented();
14229
+ } catch (err) {
14230
+ const isVerifyError = isDrawElementVerificationError(err);
14231
+ const isDeStall = isDeRendererStallError(err);
14232
+ if (!isVerifyError && !isDrawElementCaptureError(err) && !isDeStall || err instanceof RenderCancelledError || executionSignal?.aborted === true) {
14233
+ throw err;
14234
+ }
14235
+ deSelfVerifyFallback = isVerifyError;
14236
+ const t = deVerifyFallbackTelemetry(err);
14237
+ deFallbackReason = isVerifyError ? t.reason : isDeStall ? "de_renderer_stall" : "capture_error";
14238
+ deFallbackFailedDb = t.failedDb;
14239
+ deFallbackFrameIndex = t.frameIndex;
14240
+ deFallbackThresholdDb = t.thresholdDb;
14241
+ log.warn(
14242
+ "[Render] drawElement capture failed on the segmented path; re-rendering via screenshot",
14243
+ {
14244
+ error: err instanceof Error ? err.message : String(err)
14245
+ }
14246
+ );
14247
+ observability.checkpoint(
14248
+ "capture_segmented",
14249
+ "drawElement capture failed; retrying with a fresh screenshot session"
14250
+ );
14251
+ if (probeSession) {
14252
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
14253
+ const orphaned = probeSession;
14254
+ probeSession = null;
14255
+ await closeOrphanedProbeForRetry(orphaned, closeCaptureSession8, log, "segmented");
14256
+ }
14257
+ rmSync10(segmentDir, { recursive: true, force: true });
14258
+ completedSegments = /* @__PURE__ */ new Set();
14259
+ segmentManifest = emptySegmentManifest();
14260
+ dedupPerfs.length = 0;
14261
+ capturePlan = replanAfterFailure(capturePlan, {
14262
+ kind: isVerifyError ? "draw_element_verification" : "draw_element_capture"
14263
+ });
14264
+ syncCapturePlan();
14265
+ updateCaptureObservability({
14266
+ forceScreenshot: capturePlan.forceScreenshot,
14267
+ deSelfVerifyFallback,
14268
+ deFallbackReason,
14269
+ deFallbackFailedDb,
14270
+ deFallbackFrameIndex,
14271
+ deFallbackThresholdDb
14272
+ });
14273
+ segmentedRes = await invokeSegmented();
14274
+ observability.clearFailure("capture_segmented");
14275
+ }
14114
14276
  if (segmentedRes.success) {
14115
14277
  streamingHandled = true;
14116
14278
  workerCount = segmentedRes.workerCount;
@@ -14130,7 +14292,7 @@ async function executeRenderPipeline(input) {
14130
14292
  );
14131
14293
  updateCaptureObservability({ segmentRetries: segmentedRes.segmentRetries });
14132
14294
  if (job.config.keepSegments !== true) {
14133
- rmSync12(segmentDir, { recursive: true, force: true });
14295
+ rmSync10(segmentDir, { recursive: true, force: true });
14134
14296
  }
14135
14297
  } else {
14136
14298
  capturePlan = replanAfterFailure(capturePlan, { kind: "streaming_unavailable" });
@@ -14183,7 +14345,7 @@ async function executeRenderPipeline(input) {
14183
14345
  // assemble stage's `-c copy` segmentation cuts exactly on
14184
14346
  // the segment boundary. Every other format leaves the
14185
14347
  // encoder's open-GOP output untouched.
14186
- ...hlsEncoderGopLock
14348
+ ...deliveryEncoder
14187
14349
  },
14188
14350
  buildCaptureOptions,
14189
14351
  createRenderVideoFrameInjector,
@@ -14346,7 +14508,7 @@ async function executeRenderPipeline(input) {
14346
14508
  engineConfig: cfg,
14347
14509
  // Same value the streaming encoder above spreads — the disk and
14348
14510
  // chunked-concat encoders need an identical GOP lock for HLS.
14349
- ...hlsEncoderGopLock,
14511
+ ...deliveryEncoder,
14350
14512
  abortSignal: executionSignal,
14351
14513
  assertNotAborted,
14352
14514
  onProgress
@@ -14402,7 +14564,7 @@ async function executeRenderPipeline(input) {
14402
14564
  "capture_disk",
14403
14565
  "drawElement capture failed; retrying with a fresh screenshot session"
14404
14566
  );
14405
- rmSync12(framesDir, { recursive: true, force: true });
14567
+ rmSync10(framesDir, { recursive: true, force: true });
14406
14568
  mkdirSync10(framesDir, { recursive: true });
14407
14569
  resetCaptureAttemptProgress(job);
14408
14570
  dedupPerfs.length = 0;
@@ -14500,7 +14662,7 @@ async function executeRenderPipeline(input) {
14500
14662
  buildArtifactExpectation({
14501
14663
  outputFormat,
14502
14664
  durationSeconds: job.duration,
14503
- fps: fpsToNumber9(job.config.fps),
14665
+ fps: fpsToNumber8(job.config.fps),
14504
14666
  expectedFrames: captureTotalFrames,
14505
14667
  width: outputWidth,
14506
14668
  height: outputHeight
@@ -14930,11 +15092,12 @@ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
14930
15092
  import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
14931
15093
  import { extname as extname4, isAbsolute as isAbsolute4, join as join30, relative as relative4, sep as sep3 } from "node:path";
14932
15094
  import {
14933
- fpsToNumber as fpsToNumber10,
15095
+ fpsToNumber as fpsToNumber9,
14934
15096
  HF_COLOR_GRADING_ATTR as HF_COLOR_GRADING_ATTR2
14935
15097
  } from "@hyperframes/core";
14936
15098
  import {
14937
15099
  getEncoderPreset as getEncoderPreset4,
15100
+ keyframeIntervalToGopSize as keyframeIntervalToGopSize3,
14938
15101
  normalizeVp9CpuUsed,
14939
15102
  resolveConfig as resolveConfig3,
14940
15103
  declareContentAddressedUrl,
@@ -15130,6 +15293,12 @@ function assertOptionalInteger(options, field, min = 0) {
15130
15293
  throw new Error(`Render request ${field} must be an integer >= ${min}`);
15131
15294
  }
15132
15295
  }
15296
+ function assertOptionalPositiveNumber(options, field) {
15297
+ const value = options[field];
15298
+ if (value !== void 0 && !(typeof value === "number" && value > 0)) {
15299
+ throw new Error(`Render request ${field} must be a positive number`);
15300
+ }
15301
+ }
15133
15302
  function assertOptionalString(options, field) {
15134
15303
  if (options[field] !== void 0 && typeof options[field] !== "string") {
15135
15304
  throw new Error(`Render request ${field} must be a string`);
@@ -15177,10 +15346,11 @@ function assertRequestOptionScalars(options) {
15177
15346
  assertOptionalInteger(options, "workers", 1);
15178
15347
  assertOptionalInteger(options, "maxRenderMs");
15179
15348
  assertOptionalInteger(options, "crf");
15349
+ assertOptionalPositiveNumber(options, "keyframeIntervalSeconds");
15180
15350
  for (const field of ["useGpu", "debug", "outputResolutionAspectAgnostic"]) {
15181
15351
  assertOptionalBoolean(options, field);
15182
15352
  }
15183
- for (const field of ["entryFile", "videoBitrate"]) {
15353
+ for (const field of ["entryFile", "videoBitrate", "maxBitrate", "bufferSize"]) {
15184
15354
  assertOptionalString(options, field);
15185
15355
  }
15186
15356
  assertOptionalEnum(options, "strictness", ["strict", "best-effort"]);
@@ -15341,7 +15511,7 @@ function parseRenderOptions(body) {
15341
15511
  const format = parseServerFormat(body.format);
15342
15512
  const outputDynamicRange = parseServerOutputDynamicRange(body.outputDynamicRange) ?? fromRenderHdrMode(parseLegacyServerHdrMode(body.hdrMode));
15343
15513
  const encoder = parseEncoderOptions(body, format ?? "mp4");
15344
- const { crf, videoBitrate, videoFrameFormat } = encoder.ok ? encoder.options : {};
15514
+ const { crf, videoBitrate, videoFrameFormat, keyframeIntervalSeconds, maxBitrate, bufferSize } = encoder.ok ? encoder.options : {};
15345
15515
  const { variables, outputResolution, outputResolutionAspectAgnostic } = parseRenderOverrides(body);
15346
15516
  return {
15347
15517
  outputPath,
@@ -15351,6 +15521,9 @@ function parseRenderOptions(body) {
15351
15521
  maxRenderMs,
15352
15522
  crf,
15353
15523
  videoBitrate,
15524
+ keyframeIntervalSeconds,
15525
+ maxBitrate,
15526
+ bufferSize,
15354
15527
  useGpu,
15355
15528
  debug,
15356
15529
  strictness,
@@ -15385,6 +15558,9 @@ function buildRenderJobConfig(input, outputPath, log) {
15385
15558
  maxRenderMs: input.maxRenderMs,
15386
15559
  crf: input.crf,
15387
15560
  videoBitrate: input.videoBitrate,
15561
+ keyframeIntervalSeconds: input.keyframeIntervalSeconds,
15562
+ maxBitrate: input.maxBitrate,
15563
+ bufferSize: input.bufferSize,
15388
15564
  useGpu: input.useGpu,
15389
15565
  debug: input.debug,
15390
15566
  strictness: input.strictness,
@@ -15531,7 +15707,7 @@ function createArtifactStore(ttlMs) {
15531
15707
  function cleanupTempDir(dir, log) {
15532
15708
  if (!dir) return;
15533
15709
  try {
15534
- rmSync13(dir, { recursive: true, force: true });
15710
+ rmSync11(dir, { recursive: true, force: true });
15535
15711
  } catch (error) {
15536
15712
  log.warn("Failed to cleanup temp project dir", {
15537
15713
  cleanupProjectDir: dir,