@hyperframes/producer 0.8.183 → 0.8.185

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,
@@ -10672,7 +10771,6 @@ async function enforceAacTruePeak(input) {
10672
10771
  try {
10673
10772
  scratchDir = mkdtempSync3(join25(dirname9(input.audioPath), ".true-peak-"));
10674
10773
  const correctedPath = join25(scratchDir, "audio.m4a");
10675
- const correctionTargetDbfs = AAC_DELIVERY_TRUE_PEAK_DBFS - AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB;
10676
10774
  const measurements = [];
10677
10775
  let attenuationDb = 0;
10678
10776
  let measuredPath = input.audioPath;
@@ -10690,7 +10788,8 @@ async function enforceAacTruePeak(input) {
10690
10788
  return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
10691
10789
  }
10692
10790
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
10693
- attenuationDb += correctionTargetDbfs - truePeakDbfs;
10791
+ const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
10792
+ attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
10694
10793
  const result = await input.encode(
10695
10794
  input.audioPath,
10696
10795
  correctedPath,
@@ -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
  }
@@ -14042,7 +14145,14 @@ var RenderChunkValidationError = class extends Error {
14042
14145
  }
14043
14146
  };
14044
14147
  function createChunkVideoFrameInjectorFactory(frameLookup) {
14045
- return () => createVideoFrameInjector2(frameLookup);
14148
+ if (!(frameLookup instanceof Promise)) return () => createVideoFrameInjector2(frameLookup);
14149
+ return () => {
14150
+ let hook;
14151
+ return async (page, time, heldVideoTime) => {
14152
+ hook ??= createVideoFrameInjector2(await frameLookup);
14153
+ await hook?.(page, time, heldVideoTime);
14154
+ };
14155
+ };
14046
14156
  }
14047
14157
  function frameNumberFromFileName(name) {
14048
14158
  const match = /(\d+)(?=\.[^.]+$)/.exec(name);
@@ -17063,9 +17173,6 @@ function validateOutputResolutionOverride(body) {
17063
17173
  if (body.outputResolution.trim().length > 0 && normalized === void 0) {
17064
17174
  return `Invalid outputResolution "${body.outputResolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: 1080p, 4k, \u2026).`;
17065
17175
  }
17066
- if (normalized !== void 0 && (body.format === "webm" || body.format === "mov")) {
17067
- return `outputResolution is not supported with format "${body.format}": the alpha (webm/mov) capture path can't supersample. Use format "mp4", or omit outputResolution to render at the composition's native dimensions.`;
17068
- }
17069
17176
  return void 0;
17070
17177
  }
17071
17178
  function prepareProjectDirectory(projectDir, options) {
@@ -18794,6 +18901,12 @@ function validatePlanVideosForChunk(value) {
18794
18901
  );
18795
18902
  }
18796
18903
  }
18904
+ var MIN_FRAMES_PER_CHUNK_BROWSER = 20;
18905
+ function resolveChunkCaptureWorkers(framesInChunk, machineSized, maxCaptureWorkers) {
18906
+ if (maxCaptureWorkers === void 0) return machineSized;
18907
+ const byFrames = Math.floor(framesInChunk / MIN_FRAMES_PER_CHUNK_BROWSER);
18908
+ return Math.max(machineSized, Math.floor(Math.min(maxCaptureWorkers, byFrames)));
18909
+ }
18797
18910
  function resolveChunkFrameRange(slice, chunkIndex, frameRange) {
18798
18911
  if (frameRange === void 0) return { startFrame: slice.startFrame, endFrame: slice.endFrame };
18799
18912
  const { startFrame, endFrame } = frameRange;
@@ -19030,7 +19143,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19030
19143
  deferredVideos.filter((video) => remoteVideoIds.has(video.videoId))
19031
19144
  )
19032
19145
  });
19033
- const chunkWorkerCount = calculateOptimalWorkers2(framesInChunk, void 0, cfg);
19146
+ const chunkWorkerCount = resolveChunkCaptureWorkers(
19147
+ framesInChunk,
19148
+ calculateOptimalWorkers2(framesInChunk, void 0, cfg),
19149
+ options?.maxCaptureWorkers
19150
+ );
19034
19151
  let session = null;
19035
19152
  let outputKind;
19036
19153
  let framesEncoded = 0;
@@ -19042,6 +19159,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19042
19159
  const captureAttempts = [];
19043
19160
  let forceScreenshotForChunk = encoder.forceScreenshot;
19044
19161
  let shownBoxes;
19162
+ let extraction;
19045
19163
  try {
19046
19164
  if (chunkWorkerCount === 1 || !forceScreenshotForChunk) {
19047
19165
  const bootStarted = Date.now();
@@ -19098,7 +19216,7 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19098
19216
  session = null;
19099
19217
  }
19100
19218
  }
19101
- const deferredVideoFrames = planVideos ? await extractDeferredVideoFramesForChunk({
19219
+ const deferredVideoFrames = planVideos ? extractDeferredVideoFramesForChunk({
19102
19220
  planDir,
19103
19221
  planVideos,
19104
19222
  frames: chunkFrameNumbers,
@@ -19107,19 +19225,24 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19107
19225
  cfg,
19108
19226
  remoteSources,
19109
19227
  shownBoxes
19110
- }) : [];
19111
- const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? createFrameLookupTable5(
19112
- planVideos.videos,
19113
- [
19114
- ...rebuildExtractedFramesFromPlanDir(
19115
- planDir,
19116
- planVideos.extracted.filter((video) => !video.deferredRange),
19117
- v2Manifest === null ? "dense-v1" : "sparse-v2"
19118
- ),
19119
- ...deferredVideoFrames
19120
- ],
19121
- resolveRenderFpsConfig(job.config.fps).value
19228
+ }) : Promise.resolve([]);
19229
+ extraction = deferredVideoFrames;
19230
+ const videoFrameLookup = planVideos && planVideos.extracted.length > 0 ? deferredVideoFrames.then(
19231
+ (deferred) => createFrameLookupTable5(
19232
+ planVideos.videos,
19233
+ [
19234
+ ...rebuildExtractedFramesFromPlanDir(
19235
+ planDir,
19236
+ planVideos.extracted.filter((video) => !video.deferredRange),
19237
+ v2Manifest === null ? "dense-v1" : "sparse-v2"
19238
+ ),
19239
+ ...deferred
19240
+ ],
19241
+ resolveRenderFpsConfig(job.config.fps).value
19242
+ )
19122
19243
  ) : null;
19244
+ videoFrameLookup?.catch(() => {
19245
+ });
19123
19246
  const createChunkVideoFrameInjector = createChunkVideoFrameInjectorFactory(videoFrameLookup);
19124
19247
  const captureStarted = Date.now();
19125
19248
  captureMode = await runCaptureWithScreenshotFallback({
@@ -19202,7 +19325,11 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19202
19325
  }
19203
19326
  );
19204
19327
  }
19328
+ }).catch(async (captureError) => {
19329
+ await deferredVideoFrames;
19330
+ throw captureError;
19205
19331
  });
19332
+ await deferredVideoFrames;
19206
19333
  captureStageMs = Date.now() - captureStarted;
19207
19334
  applyRenderWarningPolicy(
19208
19335
  job,
@@ -19281,6 +19408,8 @@ async function renderChunk(planDir, chunkIndex, outputChunkPath, options) {
19281
19408
  }
19282
19409
  encodeStageMs = Date.now() - encodeStarted;
19283
19410
  } finally {
19411
+ await extraction?.catch(() => {
19412
+ });
19284
19413
  if (session) {
19285
19414
  try {
19286
19415
  await closeCaptureSession11(session);
@@ -19447,6 +19576,7 @@ export {
19447
19576
  plan,
19448
19577
  planV2,
19449
19578
  planV2WithPublisher,
19579
+ prepareAnimatedGifInputs,
19450
19580
  prepareCaptureSessionForReuse4 as prepareCaptureSessionForReuse,
19451
19581
  prepareHyperframeLintBody,
19452
19582
  publishPlanV2FromExecutionPlan,