@hyperframes/producer 0.8.183 → 0.8.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { type BrowserDiagnosticSummary, type RenderCaptureObservability, type Re
12
12
  export { localizeRemoteMediaSources, localizeRemoteImageSources, localizeRemoteFontFaces, } from "./services/htmlCompiler.js";
13
13
  export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, getCompositionDuration, getCapturePerfSummary, isTransientBrowserError, prepareCaptureSessionForReuse, type CaptureOptions, type CaptureSession, type CaptureResult, type CapturePerfSummary, type BeforeCaptureHook, } from "./services/frameCapture.js";
14
14
  export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
15
+ export { prepareAnimatedGifInputs } from "./services/animatedGifPrep.js";
15
16
  export { createVideoFrameInjector } from "@hyperframes/engine";
16
17
  export { resolveConfig, DEFAULT_CONFIG, type ProducerConfig } from "./config.js";
17
18
  export { type ProducerLogger, type LogLevel, createConsoleLogger, defaultLogger, } from "./logger.js";
package/dist/index.js CHANGED
@@ -8982,6 +8982,7 @@ async function runCaptureStage(input) {
8982
8982
  if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
8983
8983
  return;
8984
8984
  }
8985
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
8985
8986
  job.framesRendered = progress.capturedFrames;
8986
8987
  checkDiskProjection(progress.capturedFrames);
8987
8988
  const frameProgress = progress.capturedFrames / progress.totalFrames;
@@ -10151,6 +10152,7 @@ import {
10151
10152
  statSync as statSync5,
10152
10153
  writeFileSync as writeFileSync4
10153
10154
  } from "node:fs";
10155
+ import { readFile as readFile2 } from "node:fs/promises";
10154
10156
  import { dirname as dirname8, join as join24 } from "node:path";
10155
10157
  import {
10156
10158
  encodeFramesChunkedConcat,
@@ -10158,7 +10160,8 @@ import {
10158
10160
  formatFfmpegError,
10159
10161
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10160
10162
  resolveConfig,
10161
- runFfmpeg as runFfmpeg3
10163
+ runFfmpeg as runFfmpeg3,
10164
+ spawnStreamingEncoder as spawnStreamingEncoder4
10162
10165
  } from "@hyperframes/engine";
10163
10166
  import {
10164
10167
  clearGifFramesBeforeNext,
@@ -10295,6 +10298,124 @@ function startEncodeProgress(job, stage, onProgress) {
10295
10298
  });
10296
10299
  return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10297
10300
  }
10301
+ function resolveEngineConfig(input) {
10302
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10303
+ }
10304
+ function videoEncoderOptions(input, engineCfg) {
10305
+ const { job, preset } = input;
10306
+ return {
10307
+ fps: job.config.fps,
10308
+ width: input.width,
10309
+ height: input.height,
10310
+ codec: preset.codec,
10311
+ preset: preset.preset,
10312
+ quality: input.effectiveQuality,
10313
+ bitrate: input.effectiveBitrate,
10314
+ pixelFormat: preset.pixelFormat,
10315
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
10316
+ useGpu: job.config.useGpu,
10317
+ hdr: preset.hdr,
10318
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
10319
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
10320
+ // renders leave both undefined → preserves the existing open-GOP output.
10321
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10322
+ gopSize: input.gopSize
10323
+ };
10324
+ }
10325
+ function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10326
+ return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
10327
+ }
10328
+ async function readLandedFrame(workerPath, mergedPath) {
10329
+ if (workerPath !== void 0) {
10330
+ try {
10331
+ return await readFile2(workerPath);
10332
+ } catch (error) {
10333
+ if (error.code !== "ENOENT") throw error;
10334
+ }
10335
+ }
10336
+ return readFile2(mergedPath);
10337
+ }
10338
+ function encodeWhileCapturing(input, totalFrames) {
10339
+ const engineCfg = resolveEngineConfig(input);
10340
+ const stop = new AbortController();
10341
+ const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10342
+ const landed = /* @__PURE__ */ new Map();
10343
+ let captureDone = false;
10344
+ let framesFed = 0;
10345
+ let wake = null;
10346
+ const nudge = () => {
10347
+ wake?.();
10348
+ wake = null;
10349
+ };
10350
+ signal.addEventListener("abort", nudge, { once: true });
10351
+ let reportFed = null;
10352
+ const encoder = spawnStreamingEncoder4(
10353
+ input.videoOnlyPath,
10354
+ { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10355
+ signal,
10356
+ engineCfg
10357
+ );
10358
+ const fed = (async () => {
10359
+ const stream = await encoder;
10360
+ for (let next = 0; next < totalFrames; next++) {
10361
+ while (!landed.has(next) && !captureDone && !signal.aborted) {
10362
+ await new Promise((resolve12) => wake = resolve12);
10363
+ }
10364
+ if (signal.aborted) return;
10365
+ const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10366
+ const frame = await readLandedFrame(landed.get(next), mergedPath);
10367
+ landed.delete(next);
10368
+ if (!await stream.writeFrame(frame)) {
10369
+ throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10370
+ }
10371
+ framesFed++;
10372
+ reportFed?.(framesFed);
10373
+ }
10374
+ })().then(
10375
+ () => void 0,
10376
+ (error) => error ?? new Error("encode failed")
10377
+ );
10378
+ const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10379
+ return {
10380
+ onFrameLanded: (frame) => {
10381
+ landed.set(frame.index, frame.path);
10382
+ nudge();
10383
+ },
10384
+ finish: async () => {
10385
+ const start = Date.now();
10386
+ captureDone = true;
10387
+ input.log.info("[Render] Encoded frames while capturing", {
10388
+ framesFed,
10389
+ totalFrames
10390
+ });
10391
+ const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10392
+ reportFed = encoded;
10393
+ nudge();
10394
+ const failure = await fed;
10395
+ const result = await closeEncoder();
10396
+ input.assertNotAborted();
10397
+ if (failure === void 0 && result?.success) {
10398
+ encoded();
10399
+ return { encodeMs: Date.now() - start };
10400
+ }
10401
+ input.log.warn(
10402
+ "[Render] Encoding while capturing failed; encoding the frames after capture",
10403
+ {
10404
+ error: String(failure ?? result?.error)
10405
+ }
10406
+ );
10407
+ return runEncodeStage(input);
10408
+ },
10409
+ abort: async () => {
10410
+ stop.abort();
10411
+ await fed;
10412
+ await closeEncoder();
10413
+ },
10414
+ get framesFed() {
10415
+ return framesFed;
10416
+ }
10417
+ };
10418
+ }
10298
10419
  async function runEncodeStage(input) {
10299
10420
  const {
10300
10421
  job,
@@ -10302,16 +10423,11 @@ async function runEncodeStage(input) {
10302
10423
  outputPath,
10303
10424
  framesDir,
10304
10425
  videoOnlyPath,
10305
- width,
10306
- height,
10307
10426
  needsAlpha,
10308
10427
  hasAudio,
10309
10428
  audioOutputPath,
10310
10429
  isPngSequence,
10311
10430
  isGif,
10312
- preset,
10313
- effectiveQuality,
10314
- effectiveBitrate,
10315
10431
  enableChunkedEncode,
10316
10432
  chunkedEncodeSize,
10317
10433
  abortSignal,
@@ -10341,7 +10457,7 @@ async function runEncodeStage(input) {
10341
10457
  encoded();
10342
10458
  return { encodeMs: Date.now() - stage5Start };
10343
10459
  }
10344
- const engineCfg = input.engineConfig ?? job.config.producerConfig ?? resolveConfig();
10460
+ const engineCfg = resolveEngineConfig(input);
10345
10461
  if (isGif) {
10346
10462
  const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10347
10463
  if (hasAudio) {
@@ -10371,24 +10487,7 @@ async function runEncodeStage(input) {
10371
10487
  const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10372
10488
  const frameExt = frameFileExtension2(input.captureImageFormat);
10373
10489
  const framePattern = `frame_%06d.${frameExt}`;
10374
- const encoderOpts = {
10375
- fps: job.config.fps,
10376
- width,
10377
- height,
10378
- codec: preset.codec,
10379
- preset: preset.preset,
10380
- quality: effectiveQuality,
10381
- bitrate: effectiveBitrate,
10382
- pixelFormat: preset.pixelFormat,
10383
- vp9CpuUsed: engineCfg.vp9CpuUsed,
10384
- useGpu: job.config.useGpu,
10385
- hdr: preset.hdr,
10386
- // Distributed chunk renders pass these so the encoder writes closed-GOP
10387
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
10388
- // renders leave both undefined → preserves the existing open-GOP output.
10389
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10390
- gopSize: input.gopSize
10391
- };
10490
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
10392
10491
  const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10393
10492
  framesDir,
10394
10493
  framePattern,
@@ -13313,6 +13412,34 @@ async function executeRenderPipeline(input) {
13313
13412
  throw new Error(`Disk capture requires sdr_disk plan; got ${capturePlan.kind}`);
13314
13413
  }
13315
13414
  resetCaptureAttemptProgress(job);
13415
+ const encodeInput = {
13416
+ job,
13417
+ log,
13418
+ outputPath: stagedOutputPath,
13419
+ framesDir,
13420
+ videoOnlyPath,
13421
+ width,
13422
+ height,
13423
+ needsAlpha,
13424
+ captureImageFormat: captureOptions.format ?? "jpeg",
13425
+ hasAudio,
13426
+ audioOutputPath,
13427
+ isPngSequence,
13428
+ isGif,
13429
+ preset,
13430
+ effectiveQuality,
13431
+ effectiveBitrate,
13432
+ enableChunkedEncode,
13433
+ chunkedEncodeSize,
13434
+ engineConfig: cfg,
13435
+ // Same value the streaming encoder above spreads — the disk and
13436
+ // chunked-concat encoders need an identical GOP lock for HLS.
13437
+ ...hlsEncoderGopLock,
13438
+ abortSignal: executionSignal,
13439
+ assertNotAborted,
13440
+ onProgress
13441
+ };
13442
+ const overlap = !cfg.useDrawElement && workerSizing !== void 0 && canEncodeWhileCapturing(encodeInput, capturePlan.workerCount, workerSizing.cpuCount) ? encodeWhileCapturing(encodeInput, totalFrames) : null;
13316
13443
  const captureFrameStart = Date.now();
13317
13444
  const invokeDiskCapture = (diskPlan) => observeRenderStage(
13318
13445
  observability,
@@ -13334,13 +13461,15 @@ async function executeRenderPipeline(input) {
13334
13461
  createRenderVideoFrameInjector,
13335
13462
  abortSignal: executionSignal,
13336
13463
  assertNotAborted,
13337
- onProgress
13464
+ onProgress,
13465
+ onFrameLanded: (frame) => overlap?.onFrameLanded(frame)
13338
13466
  })
13339
13467
  );
13340
13468
  let captureRes;
13341
13469
  try {
13342
13470
  captureRes = await invokeDiskCapture(capturePlan);
13343
13471
  } catch (err) {
13472
+ await overlap?.abort();
13344
13473
  if (!isDrawElementVerificationError(err) && !isDrawElementCaptureError(err) || err instanceof RenderCancelledError || executionSignal?.aborted === true) {
13345
13474
  throw err;
13346
13475
  }
@@ -13409,33 +13538,7 @@ async function executeRenderPipeline(input) {
13409
13538
  isGif,
13410
13539
  chunkedEncode: enableChunkedEncode
13411
13540
  }),
13412
- () => runEncodeStage({
13413
- job,
13414
- log,
13415
- outputPath: stagedOutputPath,
13416
- framesDir,
13417
- videoOnlyPath,
13418
- width,
13419
- height,
13420
- needsAlpha,
13421
- captureImageFormat: captureOptions.format ?? "jpeg",
13422
- hasAudio,
13423
- audioOutputPath,
13424
- isPngSequence,
13425
- isGif,
13426
- preset,
13427
- effectiveQuality,
13428
- effectiveBitrate,
13429
- enableChunkedEncode,
13430
- chunkedEncodeSize,
13431
- engineConfig: cfg,
13432
- // Same value the streaming encoder above spreads — the disk and
13433
- // chunked-concat encoders need an identical GOP lock for HLS.
13434
- ...hlsEncoderGopLock,
13435
- abortSignal: executionSignal,
13436
- assertNotAborted,
13437
- onProgress
13438
- })
13541
+ () => overlap ? overlap.finish() : runEncodeStage(encodeInput)
13439
13542
  );
13440
13543
  perfStages.encodeMs = encodeRes.encodeMs;
13441
13544
  }
@@ -18794,6 +18897,12 @@ function validatePlanVideosForChunk(value) {
18794
18897
  );
18795
18898
  }
18796
18899
  }
18900
+ var MIN_FRAMES_PER_CHUNK_BROWSER = 20;
18901
+ function resolveChunkCaptureWorkers(framesInChunk, machineSized, maxCaptureWorkers) {
18902
+ if (maxCaptureWorkers === void 0) return machineSized;
18903
+ const byFrames = Math.floor(framesInChunk / MIN_FRAMES_PER_CHUNK_BROWSER);
18904
+ return Math.max(machineSized, Math.floor(Math.min(maxCaptureWorkers, byFrames)));
18905
+ }
18797
18906
  function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
18798
18907
  if (frameRange === void 0) return { startFrame: slice.startFrame, endFrame: slice.endFrame };
18799
18908
  const { startFrame, endFrame } = frameRange;
@@ -19030,7 +19139,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19030
19139
  deferredVideos.filter((video) => remoteVideoIds.has(video.videoId))
19031
19140
  )
19032
19141
  });
19033
- const chunkWorkerCount = calculateOptimalWorkers2(framesInChunk, void 0, cfg);
19142
+ const chunkWorkerCount = resolveChunkCaptureWorkers(
19143
+ framesInChunk,
19144
+ calculateOptimalWorkers2(framesInChunk, void 0, cfg),
19145
+ options?.maxCaptureWorkers
19146
+ );
19034
19147
  let session = null;
19035
19148
  let outputKind;
19036
19149
  let framesEncoded = 0;
@@ -19447,6 +19560,7 @@ export {
19447
19560
  plan,
19448
19561
  planV2,
19449
19562
  planV2WithPublisher,
19563
+ prepareAnimatedGifInputs,
19450
19564
  prepareCaptureSessionForReuse4 as prepareCaptureSessionForReuse,
19451
19565
  prepareHyperframeLintBody,
19452
19566
  publishPlanV2FromExecutionPlan,
@@ -8965,6 +8965,7 @@ async function runCaptureStage(input) {
8965
8965
  if (progress.capturedFrames === 0) reportWorkerStartup(job, progress, onProgress);
8966
8966
  return;
8967
8967
  }
8968
+ if (progress.landedFrame) input.onFrameLanded?.(progress.landedFrame);
8968
8969
  job.framesRendered = progress.capturedFrames;
8969
8970
  checkDiskProjection(progress.capturedFrames);
8970
8971
  const frameProgress = progress.capturedFrames / progress.totalFrames;
@@ -10134,6 +10135,7 @@ import {
10134
10135
  statSync as statSync5,
10135
10136
  writeFileSync as writeFileSync4
10136
10137
  } from "node:fs";
10138
+ import { readFile as readFile2 } from "node:fs/promises";
10137
10139
  import { dirname as dirname8, join as join24 } from "node:path";
10138
10140
  import {
10139
10141
  encodeFramesChunkedConcat,
@@ -10141,7 +10143,8 @@ import {
10141
10143
  formatFfmpegError,
10142
10144
  MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME2,
10143
10145
  resolveConfig,
10144
- runFfmpeg as runFfmpeg3
10146
+ runFfmpeg as runFfmpeg3,
10147
+ spawnStreamingEncoder as spawnStreamingEncoder4
10145
10148
  } from "@hyperframes/engine";
10146
10149
  import {
10147
10150
  clearGifFramesBeforeNext,
@@ -10278,6 +10281,124 @@ function startEncodeProgress(job, stage, onProgress) {
10278
10281
  });
10279
10282
  return (frames = total) => reportEncodeProgress(job, frames, total, onProgress, 75);
10280
10283
  }
10284
+ function resolveEngineConfig(input) {
10285
+ return input.engineConfig ?? input.job.config.producerConfig ?? resolveConfig();
10286
+ }
10287
+ function videoEncoderOptions(input, engineCfg) {
10288
+ const { job, preset } = input;
10289
+ return {
10290
+ fps: job.config.fps,
10291
+ width: input.width,
10292
+ height: input.height,
10293
+ codec: preset.codec,
10294
+ preset: preset.preset,
10295
+ quality: input.effectiveQuality,
10296
+ bitrate: input.effectiveBitrate,
10297
+ pixelFormat: preset.pixelFormat,
10298
+ vp9CpuUsed: engineCfg.vp9CpuUsed,
10299
+ useGpu: job.config.useGpu,
10300
+ hdr: preset.hdr,
10301
+ // Distributed chunk renders pass these so the encoder writes closed-GOP
10302
+ // keyframes that survive `-f concat -c copy` at assemble time. In-process
10303
+ // renders leave both undefined → preserves the existing open-GOP output.
10304
+ lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10305
+ gopSize: input.gopSize
10306
+ };
10307
+ }
10308
+ function canEncodeWhileCapturing(input, workerCount, cpuCount) {
10309
+ return workerCount > 1 && workerCount < cpuCount && !input.isPngSequence && !input.isGif && !input.enableChunkedEncode && input.preset.codec === "h264" && !input.preset.hdr && input.job.config.useGpu !== true && input.captureImageFormat === "jpeg" && input.lockGopForChunkConcat !== true;
10310
+ }
10311
+ async function readLandedFrame(workerPath, mergedPath) {
10312
+ if (workerPath !== void 0) {
10313
+ try {
10314
+ return await readFile2(workerPath);
10315
+ } catch (error) {
10316
+ if (error.code !== "ENOENT") throw error;
10317
+ }
10318
+ }
10319
+ return readFile2(mergedPath);
10320
+ }
10321
+ function encodeWhileCapturing(input, totalFrames) {
10322
+ const engineCfg = resolveEngineConfig(input);
10323
+ const stop = new AbortController();
10324
+ const signal = input.abortSignal ? AbortSignal.any([input.abortSignal, stop.signal]) : stop.signal;
10325
+ const landed = /* @__PURE__ */ new Map();
10326
+ let captureDone = false;
10327
+ let framesFed = 0;
10328
+ let wake = null;
10329
+ const nudge = () => {
10330
+ wake?.();
10331
+ wake = null;
10332
+ };
10333
+ signal.addEventListener("abort", nudge, { once: true });
10334
+ let reportFed = null;
10335
+ const encoder = spawnStreamingEncoder4(
10336
+ input.videoOnlyPath,
10337
+ { ...videoEncoderOptions(input, engineCfg), imageFormat: "jpeg" },
10338
+ signal,
10339
+ engineCfg
10340
+ );
10341
+ const fed = (async () => {
10342
+ const stream = await encoder;
10343
+ for (let next = 0; next < totalFrames; next++) {
10344
+ while (!landed.has(next) && !captureDone && !signal.aborted) {
10345
+ await new Promise((resolve10) => wake = resolve10);
10346
+ }
10347
+ if (signal.aborted) return;
10348
+ const mergedPath = join24(input.framesDir, formatCaptureFrameName(next, "jpg"));
10349
+ const frame = await readLandedFrame(landed.get(next), mergedPath);
10350
+ landed.delete(next);
10351
+ if (!await stream.writeFrame(frame)) {
10352
+ throw new Error(stream.getExitError() ?? "encoder exited before the last frame");
10353
+ }
10354
+ framesFed++;
10355
+ reportFed?.(framesFed);
10356
+ }
10357
+ })().then(
10358
+ () => void 0,
10359
+ (error) => error ?? new Error("encode failed")
10360
+ );
10361
+ const closeEncoder = async () => (await encoder.catch(() => null))?.close();
10362
+ return {
10363
+ onFrameLanded: (frame) => {
10364
+ landed.set(frame.index, frame.path);
10365
+ nudge();
10366
+ },
10367
+ finish: async () => {
10368
+ const start = Date.now();
10369
+ captureDone = true;
10370
+ input.log.info("[Render] Encoded frames while capturing", {
10371
+ framesFed,
10372
+ totalFrames
10373
+ });
10374
+ const encoded = startEncodeProgress(input.job, "Encoding video", input.onProgress);
10375
+ reportFed = encoded;
10376
+ nudge();
10377
+ const failure = await fed;
10378
+ const result = await closeEncoder();
10379
+ input.assertNotAborted();
10380
+ if (failure === void 0 && result?.success) {
10381
+ encoded();
10382
+ return { encodeMs: Date.now() - start };
10383
+ }
10384
+ input.log.warn(
10385
+ "[Render] Encoding while capturing failed; encoding the frames after capture",
10386
+ {
10387
+ error: String(failure ?? result?.error)
10388
+ }
10389
+ );
10390
+ return runEncodeStage(input);
10391
+ },
10392
+ abort: async () => {
10393
+ stop.abort();
10394
+ await fed;
10395
+ await closeEncoder();
10396
+ },
10397
+ get framesFed() {
10398
+ return framesFed;
10399
+ }
10400
+ };
10401
+ }
10281
10402
  async function runEncodeStage(input) {
10282
10403
  const {
10283
10404
  job,
@@ -10285,16 +10406,11 @@ async function runEncodeStage(input) {
10285
10406
  outputPath,
10286
10407
  framesDir,
10287
10408
  videoOnlyPath,
10288
- width,
10289
- height,
10290
10409
  needsAlpha,
10291
10410
  hasAudio,
10292
10411
  audioOutputPath,
10293
10412
  isPngSequence,
10294
10413
  isGif,
10295
- preset,
10296
- effectiveQuality,
10297
- effectiveBitrate,
10298
10414
  enableChunkedEncode,
10299
10415
  chunkedEncodeSize,
10300
10416
  abortSignal,
@@ -10324,7 +10440,7 @@ async function runEncodeStage(input) {
10324
10440
  encoded();
10325
10441
  return { encodeMs: Date.now() - stage5Start };
10326
10442
  }
10327
- const engineCfg = input.engineConfig ?? job.config.producerConfig ?? resolveConfig();
10443
+ const engineCfg = resolveEngineConfig(input);
10328
10444
  if (isGif) {
10329
10445
  const encoded = startEncodeProgress(job, "Encoding GIF", onProgress);
10330
10446
  if (hasAudio) {
@@ -10354,24 +10470,7 @@ async function runEncodeStage(input) {
10354
10470
  const videoEngineCfg = scaledEncodeTimeout > engineCfg.ffmpegEncodeTimeout ? { ...engineCfg, ffmpegEncodeTimeout: scaledEncodeTimeout } : engineCfg;
10355
10471
  const frameExt = frameFileExtension2(input.captureImageFormat);
10356
10472
  const framePattern = `frame_%06d.${frameExt}`;
10357
- const encoderOpts = {
10358
- fps: job.config.fps,
10359
- width,
10360
- height,
10361
- codec: preset.codec,
10362
- preset: preset.preset,
10363
- quality: effectiveQuality,
10364
- bitrate: effectiveBitrate,
10365
- pixelFormat: preset.pixelFormat,
10366
- vp9CpuUsed: engineCfg.vp9CpuUsed,
10367
- useGpu: job.config.useGpu,
10368
- hdr: preset.hdr,
10369
- // Distributed chunk renders pass these so the encoder writes closed-GOP
10370
- // keyframes that survive `-f concat -c copy` at assemble time. In-process
10371
- // renders leave both undefined → preserves the existing open-GOP output.
10372
- lockGopForChunkConcat: input.lockGopForChunkConcat === true,
10373
- gopSize: input.gopSize
10374
- };
10473
+ const encoderOpts = videoEncoderOptions(input, engineCfg);
10375
10474
  const encodeResult = enableChunkedEncode ? await encodeFramesChunkedConcat(
10376
10475
  framesDir,
10377
10476
  framePattern,
@@ -13286,6 +13385,34 @@ async function executeRenderPipeline(input) {
13286
13385
  throw new Error(`Disk capture requires sdr_disk plan; got ${capturePlan.kind}`);
13287
13386
  }
13288
13387
  resetCaptureAttemptProgress(job);
13388
+ const encodeInput = {
13389
+ job,
13390
+ log,
13391
+ outputPath: stagedOutputPath,
13392
+ framesDir,
13393
+ videoOnlyPath,
13394
+ width,
13395
+ height,
13396
+ needsAlpha,
13397
+ captureImageFormat: captureOptions.format ?? "jpeg",
13398
+ hasAudio,
13399
+ audioOutputPath,
13400
+ isPngSequence,
13401
+ isGif,
13402
+ preset,
13403
+ effectiveQuality,
13404
+ effectiveBitrate,
13405
+ enableChunkedEncode,
13406
+ chunkedEncodeSize,
13407
+ engineConfig: cfg,
13408
+ // Same value the streaming encoder above spreads — the disk and
13409
+ // chunked-concat encoders need an identical GOP lock for HLS.
13410
+ ...hlsEncoderGopLock,
13411
+ abortSignal: executionSignal,
13412
+ assertNotAborted,
13413
+ onProgress
13414
+ };
13415
+ const overlap = !cfg.useDrawElement && workerSizing !== void 0 && canEncodeWhileCapturing(encodeInput, capturePlan.workerCount, workerSizing.cpuCount) ? encodeWhileCapturing(encodeInput, totalFrames) : null;
13289
13416
  const captureFrameStart = Date.now();
13290
13417
  const invokeDiskCapture = (diskPlan) => observeRenderStage(
13291
13418
  observability,
@@ -13307,13 +13434,15 @@ async function executeRenderPipeline(input) {
13307
13434
  createRenderVideoFrameInjector,
13308
13435
  abortSignal: executionSignal,
13309
13436
  assertNotAborted,
13310
- onProgress
13437
+ onProgress,
13438
+ onFrameLanded: (frame) => overlap?.onFrameLanded(frame)
13311
13439
  })
13312
13440
  );
13313
13441
  let captureRes;
13314
13442
  try {
13315
13443
  captureRes = await invokeDiskCapture(capturePlan);
13316
13444
  } catch (err) {
13445
+ await overlap?.abort();
13317
13446
  if (!isDrawElementVerificationError(err) && !isDrawElementCaptureError(err) || err instanceof RenderCancelledError || executionSignal?.aborted === true) {
13318
13447
  throw err;
13319
13448
  }
@@ -13382,33 +13511,7 @@ async function executeRenderPipeline(input) {
13382
13511
  isGif,
13383
13512
  chunkedEncode: enableChunkedEncode
13384
13513
  }),
13385
- () => runEncodeStage({
13386
- job,
13387
- log,
13388
- outputPath: stagedOutputPath,
13389
- framesDir,
13390
- videoOnlyPath,
13391
- width,
13392
- height,
13393
- needsAlpha,
13394
- captureImageFormat: captureOptions.format ?? "jpeg",
13395
- hasAudio,
13396
- audioOutputPath,
13397
- isPngSequence,
13398
- isGif,
13399
- preset,
13400
- effectiveQuality,
13401
- effectiveBitrate,
13402
- enableChunkedEncode,
13403
- chunkedEncodeSize,
13404
- engineConfig: cfg,
13405
- // Same value the streaming encoder above spreads — the disk and
13406
- // chunked-concat encoders need an identical GOP lock for HLS.
13407
- ...hlsEncoderGopLock,
13408
- abortSignal: executionSignal,
13409
- assertNotAborted,
13410
- onProgress
13411
- })
13514
+ () => overlap ? overlap.finish() : runEncodeStage(encodeInput)
13412
13515
  );
13413
13516
  perfStages.encodeMs = encodeRes.encodeMs;
13414
13517
  }
@@ -73,7 +73,7 @@ export interface ChunkResult {
73
73
  sessionBootMs: number;
74
74
  captureStageMs: number;
75
75
  encodeStageMs: number;
76
- /** Capture workers used for this chunk (`calculateOptimalWorkers` result). */
76
+ /** Capture workers used for this chunk (`resolveChunkCaptureWorkers` result). */
77
77
  workers: number;
78
78
  /**
79
79
  * Effective engine mode used by every worker, after any browser fallback.
@@ -110,11 +110,18 @@ export interface RenderChunkOptions {
110
110
  * whole slice.
111
111
  */
112
112
  frameRange?: ChunkFrameRange;
113
+ /**
114
+ * Capture browsers the runtime can host. Raises the machine-wide sizing up to
115
+ * this many, one browser per 20 frames; never lowers it.
116
+ */
117
+ maxCaptureWorkers?: number;
113
118
  }
119
+ /** Capture browsers for a chunk: the machine-wide sizing, raised by the runtime's budget. */
120
+ export declare function resolveChunkCaptureWorkers(framesInChunk: number, machineSized: number, maxCaptureWorkers: number | undefined): number;
114
121
  /** The frames a chunk renders: its slice, or a validated sub-range of it. */
115
122
  export declare function resolveChunkFrameRange(slice: ChunkSliceJson, chunkIndex: number, frameRange: ChunkFrameRange | undefined): ChunkFrameRange;
116
123
  /** Compatibility-safe adapter seam for built-in or injected chunk renderers. */
117
- export type ChunkRenderer = (planDir: string, chunkIndex: number, outputChunkPath: string) => Promise<ChunkResult>;
124
+ export type ChunkRenderer = (planDir: string, chunkIndex: number, outputChunkPath: string, options?: RenderChunkOptions) => Promise<ChunkResult>;
118
125
  interface DistributedCaptureSessionDependencies {
119
126
  createCaptureSession: typeof createCaptureSession;
120
127
  assertSwiftShader: typeof assertSwiftShader;
@@ -34,7 +34,7 @@
34
34
  * `safeCleanup`, `sampleDirectoryBytes`, etc.) into a shared module so
35
35
  * the stages can import them without reaching back into the orchestrator.
36
36
  */
37
- import { type BeforeCaptureHook, type CaptureOptions, type CapturePerfSummary, type CaptureSession, type EngineConfig } from "@hyperframes/engine";
37
+ import { type BeforeCaptureHook, type CaptureOptions, type CapturePerfSummary, type CaptureSession, type EngineConfig, type LandedFrame } from "@hyperframes/engine";
38
38
  import type { FileServerHandle } from "../../fileServer.js";
39
39
  import type { ProducerLogger } from "../../../logger.js";
40
40
  import { type CaptureAttemptSummary, type ProgressCallback, type RenderConfig, type RenderJob } from "../../renderOrchestrator.js";
@@ -87,6 +87,7 @@ export interface CaptureStageInput {
87
87
  startFrame: number;
88
88
  endFrame: number;
89
89
  };
90
+ onFrameLanded?: (frame: LandedFrame) => void;
90
91
  }
91
92
  export interface CaptureStageResult {
92
93
  /** Final worker count after any adaptive retry. */