@hyperframes/engine 0.8.206 → 0.8.207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type Cap
43
43
  export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
44
44
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
45
45
  export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, capturesAlpha, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
46
- export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
46
+ export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
47
47
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
48
48
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
49
49
  export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
@@ -55,7 +55,7 @@ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCo
55
55
  export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
56
56
  export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
57
57
  export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioFailureDiagnostic, AudioSubprocessEvidence, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
58
- export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type LandedFrame, type ParallelProgress, } from "./services/parallelCoordinator.js";
58
+ export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type LandedFrame, type ParallelProgress, type ParallelWorkerPhaseDiagnostic, } from "./services/parallelCoordinator.js";
59
59
  export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
60
60
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
61
61
  export { frameFileExtension } from "./services/frameCapture.js";
@@ -74,6 +74,7 @@ export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChild
74
74
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
75
75
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
76
76
  export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
77
+ export { removeScratch, collectScratchLeftovers } from "./utils/removeScratch.js";
77
78
  export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
78
79
  export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
79
80
  export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
47
47
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
48
48
  export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, capturesAlpha, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, } from "./services/screenshotService.js";
49
49
  // ── Encoding ───────────────────────────────────────────────────────────────────
50
- export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
50
+ export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
51
51
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
52
52
  // ── Media processing ───────────────────────────────────────────────────────────
53
53
  export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
@@ -78,6 +78,7 @@ export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
78
78
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
79
79
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
80
80
  export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
81
+ export { removeScratch, collectScratchLeftovers } from "./utils/removeScratch.js";
81
82
  // drawElement self-verify comparison — shared by the streaming drain
82
83
  // (producer) and the parallel disk-path verify (parallelCoordinator).
83
84
  export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
@@ -3,7 +3,8 @@
3
3
  * so long audio is encoded as frame-aligned segments in parallel and their frames joined.
4
4
  */
5
5
  import { execFile } from "node:child_process";
6
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { removeScratch } from "../utils/removeScratch.js";
7
8
  import { dirname, join } from "node:path";
8
9
  import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
9
10
  import { runFfmpeg } from "../utils/runFfmpeg.js";
@@ -133,6 +134,6 @@ export async function encodeAac(input, outputPath, totalDuration, { signal, time
133
134
  return { ...muxed, durationMs: slowest + muxed.durationMs };
134
135
  }
135
136
  finally {
136
- rmSync(dir, { recursive: true, force: true });
137
+ removeScratch(dir);
137
138
  }
138
139
  }
@@ -11,13 +11,14 @@
11
11
  * dynamics processors and the modulated delays) have no filter that behaves the
12
12
  * same way, so preview would quietly stop predicting the render.
13
13
  */
14
- import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
14
+ import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, statSync, writeFileSync, writeSync, } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { pathToFileURL } from "node:url";
18
18
  import { getAudioFxRuntimeScript } from "@hyperframes/core/audio-fx-runtime";
19
19
  import { enabledAudioFxNodes } from "@hyperframes/core/audio-fx";
20
20
  import { serializeAutomation } from "@hyperframes/core/audio-automation";
21
+ import { removeScratch } from "../utils/removeScratch.js";
21
22
  import { acquireBrowser } from "./browserManager.js";
22
23
  import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
23
24
  import { riffChunks, wavFormatTag } from "./wavChunks.js";
@@ -460,7 +461,7 @@ async function withFxBrowser(options, body) {
460
461
  throw new AudioFxRenderError(`Audio FX failed for track ${options.trackId}: ${err.message}`);
461
462
  }
462
463
  finally {
463
- rmSync(hostDir, { recursive: true, force: true });
464
+ removeScratch(hostDir);
464
465
  await lease?.release().catch(() => undefined);
465
466
  }
466
467
  }
@@ -2,6 +2,7 @@
2
2
  import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
3
3
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
4
4
  import { renameWithRetry } from "../utils/renameWithRetry.js";
5
+ import { removeScratch } from "../utils/removeScratch.js";
5
6
  import { join, dirname, isAbsolute, relative } from "path";
6
7
  import { parseHTML } from "linkedom";
7
8
  import { extractAudioMetadata } from "../utils/ffprobe.js";
@@ -575,7 +576,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
575
576
  };
576
577
  }
577
578
  finally {
578
- rmSync(cycleDir, { recursive: true, force: true });
579
+ removeScratch(cycleDir);
579
580
  }
580
581
  }
581
582
  const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
@@ -773,7 +774,7 @@ async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, con
773
774
  return await runFfmpeg(currentArgs, { signal, timeout: ffmpegProcessTimeout });
774
775
  }
775
776
  finally {
776
- rmSync(scriptDir, { recursive: true, force: true });
777
+ removeScratch(scriptDir);
777
778
  }
778
779
  };
779
780
  let useNormalize = true;
@@ -934,7 +935,7 @@ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, sign
934
935
  return await runFfmpeg(currentArgs, { signal, timeout: ffmpegProcessTimeout });
935
936
  }
936
937
  finally {
937
- rmSync(scriptDir, { recursive: true, force: true });
938
+ removeScratch(scriptDir);
938
939
  }
939
940
  };
940
941
  let useNormalize = true;
@@ -991,8 +992,8 @@ async function sumInSlices(tracks, scratchDir, totalDuration, signal, config) {
991
992
  }
992
993
  const foldedPath = join(scratchDir, `${id}.wav`);
993
994
  const folded = await mixGroupMembersFlat([running, wavTrack(`${id}-slice`, slicePath)], foldedPath, totalDuration, signal, config);
994
- rmSync(running.srcPath, { force: true });
995
- rmSync(slicePath, { force: true });
995
+ removeScratch(running.srcPath);
996
+ removeScratch(slicePath);
996
997
  if (!folded.success)
997
998
  return folded;
998
999
  running = wavTrack(id, foldedPath);
@@ -1011,11 +1012,11 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
1011
1012
  const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
1012
1013
  if (!summed.success)
1013
1014
  return summed;
1014
- renameWithRetry(summed.track.srcPath, outputPath);
1015
+ renameWithRetry(summed.track.srcPath, outputPath, { copyFallback: true });
1015
1016
  return { success: true, degradedAutomation: summed.degradedAutomation };
1016
1017
  }
1017
1018
  finally {
1018
- rmSync(scratchDir, { recursive: true, force: true });
1019
+ removeScratch(scratchDir);
1019
1020
  }
1020
1021
  }
1021
1022
  async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
@@ -1048,7 +1049,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
1048
1049
  : mixed;
1049
1050
  }
1050
1051
  finally {
1051
- rmSync(scratchDir, { recursive: true, force: true });
1052
+ removeScratch(scratchDir);
1052
1053
  }
1053
1054
  }
1054
1055
  /**
@@ -1085,7 +1086,7 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
1085
1086
  };
1086
1087
  }
1087
1088
  if (config?.keepEncoderInputAt)
1088
- renameWithRetry(encoderInput, config.keepEncoderInputAt);
1089
+ renameWithRetry(encoderInput, config.keepEncoderInputAt, { copyFallback: true });
1089
1090
  return {
1090
1091
  ...sum,
1091
1092
  outputPath,
@@ -706,6 +706,8 @@ async function launchBrowser(fingerprint) {
706
706
  }
707
707
  function forceCloseBrowserProcess(browser) {
708
708
  const proc = browser.process?.();
709
+ for (const stream of proc?.stdio ?? [])
710
+ stream?.on("error", () => { });
709
711
  if (proc && !proc.killed) {
710
712
  try {
711
713
  proc.kill("SIGKILL");
@@ -87,6 +87,14 @@ export declare function appendLockedGopArgs(args: string[], gopSize: number): vo
87
87
  * open-GOP references. `repeat-headers=1` keeps each boundary self-contained.
88
88
  */
89
89
  export declare function lockedGopCodecParams(codec: "h264" | "h265", gopSize: number): string;
90
+ /** Frames per keyframe interval: the nearest whole frame, never below one. */
91
+ export declare function keyframeIntervalToGopSize(fps: Fps, seconds: number): number;
92
+ /** `-maxrate` / `-bufsize` for the VBV cap, written once for both arg builders. Buffer defaults to 2x. */
93
+ export declare function appendRateCapArgs(args: string[], options: {
94
+ codec?: string;
95
+ maxBitrate?: string;
96
+ bufferSize?: string;
97
+ }): void;
90
98
  export declare function buildEncoderArgs(options: EncoderOptions, inputArgs: string[], outputPath: string, gpuEncoder?: GpuEncoder): string[];
91
99
  export declare function encodeFramesFromDir(framesDir: string, framePattern: string, outputPath: string, options: EncoderOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>, onFramesEncoded?: (frames: number) => void): Promise<EncodeResult>;
92
100
  export declare function buildConcatArgs(concatListPath: string, outputPath: string): string[];
@@ -128,6 +128,38 @@ export function lockedGopCodecParams(codec, gopSize) {
128
128
  const shared = "scenecut=0:open-gop=0:repeat-headers=1";
129
129
  return codec === "h264" ? shared : `keyint=${gopSize}:min-keyint=${gopSize}:${shared}`;
130
130
  }
131
+ /** Frames per keyframe interval: the nearest whole frame, never below one. */
132
+ export function keyframeIntervalToGopSize(fps, seconds) {
133
+ return Math.max(1, Math.round(fpsToNumber(fps) * seconds));
134
+ }
135
+ const BITRATE_PATTERN = /^(\d+(?:\.\d+)?)([kKmMgG]?)$/;
136
+ function bitrateToBitsPerSecond(value) {
137
+ const match = BITRATE_PATTERN.exec(value);
138
+ if (!match)
139
+ throw new Error(`[chunkEncoder] invalid bitrate ${JSON.stringify(value)}`);
140
+ const unit = match[2].toLowerCase();
141
+ const scale = { g: 1_000_000_000, m: 1_000_000, k: 1_000 }[unit] ?? 1;
142
+ return Number(match[1]) * scale;
143
+ }
144
+ /** `-maxrate` / `-bufsize` for the VBV cap, written once for both arg builders. Buffer defaults to 2x. */
145
+ export function appendRateCapArgs(args, options) {
146
+ const { maxBitrate, bufferSize } = options;
147
+ if (maxBitrate === undefined) {
148
+ if (bufferSize !== undefined)
149
+ throw new Error("[chunkEncoder] bufferSize requires maxBitrate");
150
+ return;
151
+ }
152
+ const codec = options.codec ?? "h264";
153
+ if (codec !== "h264" && codec !== "h265") {
154
+ throw new Error(`[chunkEncoder] maxBitrate is not supported for codec ${codec}`);
155
+ }
156
+ const cap = bitrateToBitsPerSecond(maxBitrate);
157
+ const buffer = bufferSize !== undefined ? bitrateToBitsPerSecond(bufferSize) : cap * 2;
158
+ if (cap <= 0 || buffer <= 0) {
159
+ throw new Error("[chunkEncoder] maxBitrate and bufferSize must be positive");
160
+ }
161
+ args.push("-maxrate", String(Math.round(cap)), "-bufsize", String(Math.round(buffer)));
162
+ }
131
163
  export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = null) {
132
164
  const { fps, codec = "h264", preset = "medium", quality = 23, bitrate, pixelFormat = "yuv420p", vp9CpuUsed, useGpu = false, } = options;
133
165
  // libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd
@@ -142,6 +174,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
142
174
  // Without -xerror, ffmpeg ends the video at the first frame file it cannot read and still exits 0.
143
175
  const args = ["-xerror", ...inputArgs, "-r", fpsToFfmpegArg(fps)];
144
176
  const shouldUseGpu = useGpu && gpuEncoder !== null;
177
+ appendRateCapArgs(args, options);
145
178
  if (codec === "h264" || codec === "h265") {
146
179
  if (shouldUseGpu) {
147
180
  const encoderName = getGpuEncoderName(gpuEncoder, codec);
@@ -9,6 +9,8 @@ export interface EncoderOptions {
9
9
  preset?: string;
10
10
  quality?: number;
11
11
  bitrate?: string;
12
+ maxBitrate?: string;
13
+ bufferSize?: string;
12
14
  pixelFormat?: string;
13
15
  /** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
14
16
  vp9CpuUsed?: number;
@@ -3,5 +3,6 @@ export declare class DrawElementCaptureError extends Error {
3
3
  readonly frameIndex: number;
4
4
  constructor(frameIndex: number, reason: string, cause?: unknown);
5
5
  }
6
+ export declare function asDrawElementCaptureFailure(frameIndex: number, encodeResult: Promise<Buffer>): Promise<Buffer>;
6
7
  /** Structural matching survives engine/producer copies and worker error wrappers. */
7
8
  export declare function isDrawElementCaptureError(error: unknown): boolean;
@@ -9,6 +9,19 @@ export class DrawElementCaptureError extends Error {
9
9
  this.frameIndex = frameIndex;
10
10
  }
11
11
  }
12
+ function markRejectionHandled(promise) {
13
+ promise.catch(() => { });
14
+ }
15
+ export function asDrawElementCaptureFailure(frameIndex, encodeResult) {
16
+ const wrapped = encodeResult.catch((cause) => {
17
+ if (isDrawElementCaptureError(cause))
18
+ throw cause;
19
+ const reason = cause instanceof Error ? cause.message : String(cause);
20
+ throw new DrawElementCaptureError(frameIndex, reason, cause);
21
+ });
22
+ markRejectionHandled(wrapped);
23
+ return wrapped;
24
+ }
12
25
  /** Structural matching survives engine/producer copies and worker error wrappers. */
13
26
  export function isDrawElementCaptureError(error) {
14
27
  let current = error;
@@ -11,7 +11,7 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END, RUNTIME_BOOTSTRAP_ATTR, SCENE_CALLBACK_ERROR_PREFIX, } from "@hyperframes/core";
13
13
  import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
14
- import { DrawElementCaptureError } from "./drawElementCaptureError.js";
14
+ import { asDrawElementCaptureFailure, DrawElementCaptureError } from "./drawElementCaptureError.js";
15
15
  import { encodePng } from "../utils/alphaBlit.js";
16
16
  import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
17
17
  import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
@@ -3648,7 +3648,8 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
3648
3648
  // syncToPaintEvent = true); see initDrawElementOrTransparentBackground. The
3649
3649
  // BeginFrame branch present in the synchronous captureFrameCore is therefore
3650
3650
  // unreachable here and intentionally omitted.
3651
- const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
3651
+ const produced = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
3652
+ const encodeResult = asDrawElementCaptureFailure(frameIndex, produced.encodeResult);
3652
3653
  const captureTimeMs = Date.now() - startTime;
3653
3654
  session.capturePerf.frames += 1;
3654
3655
  session.capturePerf.seekMs += seekMs;
@@ -3763,7 +3764,10 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3763
3764
  const encodeResult = encodeResults[i];
3764
3765
  if (frameIndex === undefined || !encodeResult)
3765
3766
  break;
3766
- results.push({ frameIndex, encodeResult });
3767
+ results.push({
3768
+ frameIndex,
3769
+ encodeResult: asDrawElementCaptureFailure(frameIndex, encodeResult),
3770
+ });
3767
3771
  }
3768
3772
  if (failedAt !== null) {
3769
3773
  // `error` is a plain string here (produceDrawElementFrameBatch returns it
@@ -74,6 +74,7 @@ export interface ParallelWorkerPhaseDiagnostic {
74
74
  browserVersion: string;
75
75
  canvasDrawElement: boolean | "unknown";
76
76
  gpuBackend: string;
77
+ readStep?: () => string | undefined;
77
78
  }
78
79
  export interface WorkerSizingConfig extends Partial<Pick<EngineConfig, "concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold">> {
79
80
  /**
@@ -237,6 +238,7 @@ export interface ParallelCaptureHooks {
237
238
  * frame (the ordered writer) with the original error, not a stall.
238
239
  */
239
240
  onWorkerFailure?: (failure: CaptureFailure) => void;
241
+ onWorkerOperation?: (diagnostic: ParallelWorkerPhaseDiagnostic) => void;
240
242
  }
241
243
  /**
242
244
  * The pool's single failure gate. Both halves of the contract the streaming
@@ -320,20 +320,22 @@ function resolveParallelWorkerTimeoutMs(enabled) {
320
320
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
321
321
  }
322
322
  // fallow-ignore-next-line complexity
323
- async function captureFrameRange(session, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase) {
323
+ async function captureFrameRange(session, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase, onWorkerOperation) {
324
324
  let framesCaptured = 0;
325
325
  const outputOffset = task.outputFrameOffset ?? 0;
326
326
  const stride = task.frameStride ?? 1;
327
327
  const runCaptureOperation = (phase, frameIndex, operation) => {
328
- const diagnostic = diagnosticFor(phase, frameIndex);
328
+ const diagnostic = { ...diagnosticFor(phase, frameIndex), readStep: () => session.captureStep };
329
329
  if (framesCaptured === 0)
330
330
  onWorkerPhase?.(diagnostic);
331
+ onWorkerOperation?.(diagnostic);
331
332
  return withParallelWorkerDeadline(operation(), diagnostic, phaseTimeoutMs, signal);
332
333
  };
333
334
  const awaitEncode = (frameIndex, encodeResult) => {
334
335
  const diagnostic = diagnosticFor("frame_encode", frameIndex);
335
336
  if (framesCaptured === 0)
336
337
  onWorkerPhase?.(diagnostic);
338
+ onWorkerOperation?.(diagnostic);
337
339
  return withParallelWorkerDeadline(encodeResult, diagnostic, phaseTimeoutMs, signal);
338
340
  };
339
341
  // Depth-2 pipelined drawElement produce (HF_DE_PARALLEL_STREAM spike): frame
@@ -550,7 +552,7 @@ export async function verifyDiskDrawElementSamples(session, task, streaming) {
550
552
  // flagged only because the disk self-verify call shifted its line range into
551
553
  // the changed-code audit. Not restructured by this PR.
552
554
  // fallow-ignore-next-line complexity
553
- async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCaptureHook, signal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase) {
555
+ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCaptureHook, signal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase, onWorkerOperation) {
554
556
  const startTime = Date.now();
555
557
  let framesCaptured = 0;
556
558
  if (!existsSync(task.outputDir))
@@ -605,7 +607,7 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
605
607
  canvasDrawElement = activeSession.captureMode === "drawelement";
606
608
  gpuBackend = activeSession.gpuRenderer ?? "unknown";
607
609
  logParDebug(() => `[par:w${task.workerId}] init done (mode=${session?.captureMode} workerEncode=${session?.workerEncodeEnabled === true})`);
608
- framesCaptured = await captureFrameRange(activeSession, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase);
610
+ framesCaptured = await captureFrameRange(activeSession, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase, onWorkerOperation);
609
611
  await verifyDiskDrawElementSamples(activeSession, task, Boolean(onFrameBuffer));
610
612
  perf = getCapturePerfSummary(activeSession);
611
613
  return {
@@ -775,7 +777,7 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
775
777
  hooks,
776
778
  });
777
779
  const onFailure = failureHandler.onFailure;
778
- const results = await Promise.all(tasks.map((task) => executeWorkerTask(task, serverUrl, workerCaptureOptions, createBeforeCaptureHook, workerSignal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase)));
780
+ const results = await Promise.all(tasks.map((task) => executeWorkerTask(task, serverUrl, workerCaptureOptions, createBeforeCaptureHook, workerSignal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase, hooks?.onWorkerOperation)));
779
781
  flagSilentWorkerExits(results);
780
782
  const errors = results.filter((r) => r.failure || r.error);
781
783
  if (errors.length > 0) {
@@ -38,6 +38,8 @@ export interface StreamingEncoderOptions {
38
38
  preset?: string;
39
39
  quality?: number;
40
40
  bitrate?: string;
41
+ maxBitrate?: string;
42
+ bufferSize?: string;
41
43
  pixelFormat?: string;
42
44
  /** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
43
45
  vp9CpuUsed?: number;
@@ -30,7 +30,7 @@ import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
30
30
  import { appendVp9CpuUsedArg } from "./vp9Options.js";
31
31
  import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
32
32
  import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
33
- import { appendLockedGopArgs, lockedGopCodecParams, resolveLockedGopSize } from "./chunkEncoder.js";
33
+ import { appendLockedGopArgs, appendRateCapArgs, lockedGopCodecParams, resolveLockedGopSize, } from "./chunkEncoder.js";
34
34
  export function createFrameReorderBuffer(startFrame, endFrame) {
35
35
  let cursor = startFrame;
36
36
  let aborted = null;
@@ -121,6 +121,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
121
121
  }
122
122
  args.push("-r", fpsToFfmpegArg(fps));
123
123
  const shouldUseGpu = useGpu && gpuEncoder !== null;
124
+ appendRateCapArgs(args, options);
124
125
  if (codec === "h264" || codec === "h265") {
125
126
  if (shouldUseGpu) {
126
127
  const encoderName = getGpuEncoderName(gpuEncoder, codec);
@@ -623,9 +623,6 @@ outputDirOverride) {
623
623
  throw new Error("frameRanges extraction needs a constant-frame-rate source");
624
624
  }
625
625
  const args = urlInputArgs(videoPath);
626
- if (decodeWithVideoToolbox) {
627
- args.push("-hwaccel", "videotoolbox");
628
- }
629
626
  // Always force the alpha-aware decoder on codecs that can carry alpha. The
630
627
  // alternative — gating on `metadata.hasAlpha` — relies on tag detection that
631
628
  // has at least three known failure modes: case-sensitivity across ffmpeg
@@ -708,9 +705,9 @@ outputDirOverride) {
708
705
  // emits that last slot: the fps filter ends a `-t` window at the nearest slot. A
709
706
  // decimal rate counts zero frames here and keeps the `-t` window.
710
707
  const boundVfrFrameCount = metadata.isVFR && totalFrames > 0;
711
- const processResult = sampleAtOutputFps && (options.frameRanges || boundVfrFrameCount || totalFrames > segmentFrames)
712
- ? await runSegmentedExtraction({
713
- decodeArgs,
708
+ const extract = (hwaccel) => sampleAtOutputFps && (options.frameRanges || boundVfrFrameCount || totalFrames > segmentFrames)
709
+ ? runSegmentedExtraction({
710
+ decodeArgs: [...hwaccel, ...decodeArgs],
714
711
  filterAndEncodeArgs: (seek) => [...filterArgsFor(seek), ...encodeArgs],
715
712
  videoPath,
716
713
  startTime,
@@ -719,7 +716,14 @@ outputDirOverride) {
719
716
  segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
720
717
  runOptions,
721
718
  })
722
- : await inExtractionSlot(() => runFfmpeg([...args, ...filterArgsFor(startTime), ...encodeArgs], runOptions), signal);
719
+ : inExtractionSlot(() => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs], runOptions), signal);
720
+ let processResult = await extract(decodeWithVideoToolbox ? ["-hwaccel", "videotoolbox"] : []);
721
+ const ffmpegExitedFailing = processResult.terminationReason === "exit" &&
722
+ !processResult.success &&
723
+ !processResult.failureReason;
724
+ const videoToolboxWasOptional = decodeWithVideoToolbox && !keepsHdrForChrome;
725
+ if (ffmpegExitedFailing && videoToolboxWasOptional)
726
+ processResult = await extract([]);
723
727
  if (processResult.failureReason === "external_interruption") {
724
728
  throw new VideoSourceExtractionError("external_interruption", true, "Video frame extraction interrupted by host lifecycle", `FFmpeg exited with code ${processResult.exitCode}: ${processResult.stderr.slice(-500)}`);
725
729
  }
@@ -0,0 +1,5 @@
1
+ /** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
2
+ * delete) leaves it for the render's own cleanup and never fails the audio it served. */
3
+ export declare function removeScratch(path: string): void;
4
+ /** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
5
+ export declare function collectScratchLeftovers<T>(run: () => Promise<T>, report: (left: string[]) => void): Promise<T>;
@@ -0,0 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { rmSync } from "node:fs";
3
+ const collecting = new AsyncLocalStorage();
4
+ /** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
5
+ * delete) leaves it for the render's own cleanup and never fails the audio it served. */
6
+ export function removeScratch(path) {
7
+ try {
8
+ rmSync(path, { recursive: true, force: true });
9
+ }
10
+ catch {
11
+ collecting.getStore()?.push(path);
12
+ }
13
+ }
14
+ /** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
15
+ export async function collectScratchLeftovers(run, report) {
16
+ const left = [];
17
+ try {
18
+ return await collecting.run(left, run);
19
+ }
20
+ finally {
21
+ report(left);
22
+ }
23
+ }
@@ -1,12 +1,16 @@
1
1
  interface Options {
2
2
  platform?: NodeJS.Platform;
3
+ /** Copy the bytes when Windows keeps refusing (a TEMP that denies delete). Not atomic: scratch files only. */
4
+ copyFallback?: boolean;
3
5
  }
4
6
  /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
5
7
  export declare function renameWithRetry(from: string, to: string, options?: Options & {
6
8
  rename?: (from: string, to: string) => void;
9
+ copy?: (from: string, to: string) => void;
7
10
  }): void;
8
11
  /** Async twin of {@link renameWithRetry}. */
9
12
  export declare function renameWithRetryAsync(from: string, to: string, options?: Options & {
10
13
  rename?: (from: string, to: string) => Promise<void>;
14
+ copy?: (from: string, to: string) => Promise<void>;
11
15
  }): Promise<void>;
12
16
  export {};
@@ -1,5 +1,6 @@
1
- import { renameSync } from "node:fs";
2
- import { rename } from "node:fs/promises";
1
+ import { copyFileSync, renameSync } from "node:fs";
2
+ import { copyFile, rename } from "node:fs/promises";
3
+ import { removeScratch } from "./removeScratch.js";
3
4
  // Windows reports EPERM/EACCES/EBUSY while antivirus or a just-exited process still holds the
4
5
  // file; the hold clears within milliseconds. Total wait before giving up: about 1.3 s.
5
6
  const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"]);
@@ -9,12 +10,21 @@ function isRetryable(error, platform) {
9
10
  return false;
10
11
  return RETRYABLE.has(error?.code ?? "");
11
12
  }
13
+ /** What a refused rename does next: wait and try again, fall back to a copy, or give up with the error. */
14
+ function nextStep(error, attempt, options, platform) {
15
+ if (!isRetryable(error, platform))
16
+ return "throw";
17
+ if (attempt < BACKOFF_MS.length)
18
+ return "wait";
19
+ return options.copyFallback ? "copy" : "throw";
20
+ }
12
21
  function sleepSync(ms) {
13
22
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
23
  }
15
24
  /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
16
25
  export function renameWithRetry(from, to, options = {}) {
17
26
  const doRename = options.rename ?? renameSync;
27
+ const doCopy = options.copy ?? copyFileSync;
18
28
  const platform = options.platform ?? process.platform;
19
29
  for (let attempt = 0;; attempt++) {
20
30
  try {
@@ -22,15 +32,28 @@ export function renameWithRetry(from, to, options = {}) {
22
32
  return;
23
33
  }
24
34
  catch (error) {
25
- if (attempt >= BACKOFF_MS.length || !isRetryable(error, platform))
35
+ const step = nextStep(error, attempt, options, platform);
36
+ if (step === "throw")
37
+ throw error;
38
+ if (step === "wait") {
39
+ sleepSync(BACKOFF_MS[attempt]);
40
+ continue;
41
+ }
42
+ try {
43
+ doCopy(from, to);
44
+ }
45
+ catch {
26
46
  throw error;
27
- sleepSync(BACKOFF_MS[attempt]);
47
+ }
48
+ removeScratch(from);
49
+ return;
28
50
  }
29
51
  }
30
52
  }
31
53
  /** Async twin of {@link renameWithRetry}. */
32
54
  export async function renameWithRetryAsync(from, to, options = {}) {
33
55
  const doRename = options.rename ?? rename;
56
+ const doCopy = options.copy ?? copyFile;
34
57
  const platform = options.platform ?? process.platform;
35
58
  for (let attempt = 0;; attempt++) {
36
59
  try {
@@ -38,9 +61,21 @@ export async function renameWithRetryAsync(from, to, options = {}) {
38
61
  return;
39
62
  }
40
63
  catch (error) {
41
- if (attempt >= BACKOFF_MS.length || !isRetryable(error, platform))
64
+ const step = nextStep(error, attempt, options, platform);
65
+ if (step === "throw")
42
66
  throw error;
43
- await new Promise((resolve) => setTimeout(resolve, BACKOFF_MS[attempt]));
67
+ if (step === "wait") {
68
+ await new Promise((resolve) => setTimeout(resolve, BACKOFF_MS[attempt]));
69
+ continue;
70
+ }
71
+ try {
72
+ await doCopy(from, to);
73
+ }
74
+ catch {
75
+ throw error;
76
+ }
77
+ removeScratch(from);
78
+ return;
44
79
  }
45
80
  }
46
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.206",
3
+ "version": "0.8.207",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "linkedom": "^0.18.12",
51
51
  "puppeteer": "^25.8.0",
52
52
  "puppeteer-core": "^25.8.0",
53
- "@hyperframes/core": "^0.8.206",
54
- "@hyperframes/parsers": "^0.8.206"
53
+ "@hyperframes/core": "^0.8.207",
54
+ "@hyperframes/parsers": "^0.8.207"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",