@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.
@@ -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,
@@ -10645,7 +10744,6 @@ async function enforceAacTruePeak(input) {
10645
10744
  try {
10646
10745
  scratchDir = mkdtempSync3(join25(dirname9(input.audioPath), ".true-peak-"));
10647
10746
  const correctedPath = join25(scratchDir, "audio.m4a");
10648
- const correctionTargetDbfs = AAC_DELIVERY_TRUE_PEAK_DBFS - AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB;
10649
10747
  const measurements = [];
10650
10748
  let attenuationDb = 0;
10651
10749
  let measuredPath = input.audioPath;
@@ -10663,7 +10761,8 @@ async function enforceAacTruePeak(input) {
10663
10761
  return { success: true, loweredDb: Number((-attenuationDb).toFixed(2)) };
10664
10762
  }
10665
10763
  if (pass === MAX_TRUE_PEAK_CORRECTION_PASSES) break;
10666
- attenuationDb += correctionTargetDbfs - truePeakDbfs;
10764
+ const headroomDb = AAC_TRUE_PEAK_CORRECTION_HEADROOM_DB * 2 ** pass;
10765
+ attenuationDb += AAC_DELIVERY_TRUE_PEAK_DBFS - headroomDb - truePeakDbfs;
10667
10766
  const result = await input.encode(
10668
10767
  input.audioPath,
10669
10768
  correctedPath,
@@ -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
  }
@@ -14370,9 +14473,6 @@ function validateOutputResolutionOverride(body) {
14370
14473
  if (body.outputResolution.trim().length > 0 && normalized === void 0) {
14371
14474
  return `Invalid outputResolution "${body.outputResolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: 1080p, 4k, \u2026).`;
14372
14475
  }
14373
- if (normalized !== void 0 && (body.format === "webm" || body.format === "mov")) {
14374
- 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.`;
14375
- }
14376
14476
  return void 0;
14377
14477
  }
14378
14478
  function prepareProjectDirectory(projectDir, options) {
@@ -17,7 +17,7 @@ export declare class RenderChunkValidationError extends Error {
17
17
  constructor(code: RenderChunkValidationCode, message: string);
18
18
  }
19
19
  /** One injector per browser session: a hook reused on a fresh page can skip its first injection. */
20
- export declare function createChunkVideoFrameInjectorFactory(frameLookup: FrameLookupTable | null): () => BeforeCaptureHook | null;
20
+ export declare function createChunkVideoFrameInjectorFactory(frameLookup: FrameLookupTable | Promise<FrameLookupTable> | null): () => BeforeCaptureHook | null;
21
21
  /**
22
22
  * Rebuilds `ExtractedFrames[]` from `<planDir>/video-frames/<videoId>/`. `framePaths` keys are 0-based
23
23
  * frame indexes (what `getFrameAtTime` reads); any other key drops every video's first-paint frame.
@@ -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. */
@@ -26,8 +26,12 @@
26
26
  * branch + same args.
27
27
  * - `Encoding failed: <err>` throws on the encoder's
28
28
  * `success: false`.
29
+ *
30
+ * `encodeWhileCapturing` runs the same mp4 encode during multi-worker disk
31
+ * capture, feeding frames in order as they land, and falls back to this
32
+ * stage's encode if it fails.
29
33
  */
30
- import { getEncoderPreset, type EngineConfig } from "@hyperframes/engine";
34
+ import { getEncoderPreset, type EngineConfig, type LandedFrame } from "@hyperframes/engine";
31
35
  import type { ProducerLogger } from "../../../logger.js";
32
36
  import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
33
37
  export interface EncodeStageInput {
@@ -71,7 +75,7 @@ export interface EncodeStageInput {
71
75
  enableChunkedEncode: boolean;
72
76
  chunkedEncodeSize: number;
73
77
  /** Already-resolved engine config from the orchestrator; direct callers fall back below. */
74
- engineConfig?: Pick<EngineConfig, "ffmpegEncodeTimeout" | "vp9CpuUsed">;
78
+ engineConfig?: Pick<EngineConfig, "ffmpegEncodeTimeout" | "vp9CpuUsed"> & Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>;
75
79
  abortSignal: AbortSignal | undefined;
76
80
  assertNotAborted: () => void;
77
81
  onProgress?: ProgressCallback;
@@ -97,4 +101,24 @@ export interface EncodeStageResult {
97
101
  /** Wall-clock ms for the encode (or png-copy) phase. */
98
102
  encodeMs: number;
99
103
  }
104
+ /**
105
+ * Software libx264 from JPEG only: the streaming encoder then runs the disk encoder's
106
+ * arguments (same bytes). Needs a core the capture workers leave free.
107
+ */
108
+ export declare function canEncodeWhileCapturing(input: EncodeStageInput, workerCount: number, cpuCount: number): boolean;
109
+ export interface EncodeWhileCapturing {
110
+ /** A worker finished writing this frame. */
111
+ onFrameLanded: (frame: LandedFrame) => void;
112
+ /** Capture succeeded and every frame is in `framesDir`: encode the rest. */
113
+ finish: () => Promise<EncodeStageResult>;
114
+ /** Capture failed: stop the encoder and wait for it to exit. */
115
+ abort: () => Promise<void>;
116
+ /** Frames handed to the encoder so far. */
117
+ readonly framesFed: number;
118
+ }
119
+ /**
120
+ * Feeds frames 0, 1, 2… to the encoder as each lands; capture never waits on it.
121
+ * On failure `finish` re-runs `runEncodeStage` on the same frames.
122
+ */
123
+ export declare function encodeWhileCapturing(input: EncodeStageInput, totalFrames: number): EncodeWhileCapturing;
100
124
  export declare function runEncodeStage(input: EncodeStageInput): Promise<EncodeStageResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/producer",
3
- "version": "0.8.183",
3
+ "version": "0.8.185",
4
4
  "description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,11 +35,11 @@
35
35
  "postcss": "^8.4.0",
36
36
  "puppeteer": "^25.2.1",
37
37
  "puppeteer-core": "^25.2.1",
38
- "@hyperframes/core": "0.8.183",
39
- "@hyperframes/engine": "0.8.183",
40
- "@hyperframes/parsers": "0.8.183",
41
- "@hyperframes/studio-server": "0.8.183",
42
- "@hyperframes/lint": "0.8.183"
38
+ "@hyperframes/lint": "0.8.185",
39
+ "@hyperframes/parsers": "0.8.185",
40
+ "@hyperframes/studio-server": "0.8.185",
41
+ "@hyperframes/engine": "0.8.185",
42
+ "@hyperframes/core": "0.8.185"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^25.0.10",
@@ -48,7 +48,7 @@
48
48
  "tsx": "^4.21.0",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^4.1.11",
51
- "@hyperframes/aws-lambda": "0.8.183"
51
+ "@hyperframes/aws-lambda": "0.8.185"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"