@hyperframes/engine 0.9.5 → 0.9.6

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/config.d.ts CHANGED
@@ -74,6 +74,7 @@ export interface EngineConfig {
74
74
  * true. Kill switch: `HF_DE_WORKER_ENCODE=false`.
75
75
  */
76
76
  enableDrawElementWorkerEncode: boolean;
77
+ useInPageEncode: boolean;
77
78
  /**
78
79
  * INTERNAL. Set by resolveConfig when it disabled enablePageSideCompositing
79
80
  * solely because drawElement was on. Lets the producer's compile-time gates
package/dist/config.js CHANGED
@@ -32,6 +32,7 @@ export const DEFAULT_CONFIG = {
32
32
  videoLayerPassthrough: false,
33
33
  useDrawElement: true,
34
34
  enableDrawElementWorkerEncode: true,
35
+ useInPageEncode: false,
35
36
  // Auto-detected per host in `resolveConfig`; defaults off for the raw
36
37
  // DEFAULT_CONFIG (used directly by tests and worker-sizing fallbacks).
37
38
  lowMemoryMode: false,
@@ -76,6 +77,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [
76
77
  "videoLayerPassthrough",
77
78
  "useDrawElement",
78
79
  "enableDrawElementWorkerEncode",
80
+ "useInPageEncode",
79
81
  "lowMemoryMode",
80
82
  "enablePageSideCompositing",
81
83
  "enableChunkedEncode",
@@ -517,6 +519,7 @@ export function resolveConfig(overrides) {
517
519
  videoLayerPassthrough: envBool("HF_VIDEO_LAYER_PASSTHROUGH", DEFAULT_CONFIG.videoLayerPassthrough),
518
520
  useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement),
519
521
  enableDrawElementWorkerEncode: envBool("HF_DE_WORKER_ENCODE", DEFAULT_CONFIG.enableDrawElementWorkerEncode),
522
+ useInPageEncode: envBool("HF_INPAGE_ENCODE", DEFAULT_CONFIG.useInPageEncode),
520
523
  lowMemoryMode: resolveLowMemoryMode(),
521
524
  enablePageSideCompositing: envBool("HF_PAGE_SIDE_COMPOSITING", DEFAULT_CONFIG.enablePageSideCompositing),
522
525
  vp9CpuUsed: envVp9CpuUsed(),
package/dist/index.d.ts CHANGED
@@ -48,6 +48,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
48
48
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, type ProresProfile, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
49
49
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
50
50
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
51
+ export { spawnChunkStreamingEncoder, buildChunkStreamingArgs, BT709_LIMITED, type ChunkColour, type ChunkStreamingOptions, } from "./services/chunkStreamingEncoder.js";
52
+ export { resolveInPageEncode, type InPageEncodeDecision, type InPageEncodeInput, } from "./services/inPageEncodeEligibility.js";
51
53
  export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, 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, buildVideoLayerInput, type VideoLayerInput, } from "./services/videoFrameExtractor.js";
52
54
  export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
53
55
  export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
package/dist/index.js CHANGED
@@ -51,6 +51,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
51
51
  // ── Encoding ───────────────────────────────────────────────────────────────────
52
52
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
53
53
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
54
+ export { spawnChunkStreamingEncoder, buildChunkStreamingArgs, BT709_LIMITED, } from "./services/chunkStreamingEncoder.js";
55
+ export { resolveInPageEncode, } from "./services/inPageEncodeEligibility.js";
54
56
  // ── Media processing ───────────────────────────────────────────────────────────
55
57
  export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, buildVideoLayerInput, } from "./services/videoFrameExtractor.js";
56
58
  export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
@@ -0,0 +1,16 @@
1
+ import type { Fps } from "@hyperframes/core";
2
+ import type { EngineConfig } from "../config.js";
3
+ import { type StreamingEncoder } from "./streamingEncoder.js";
4
+ export interface ChunkColour {
5
+ primaries: number;
6
+ transfer: number;
7
+ matrix: number;
8
+ fullRange: boolean;
9
+ }
10
+ export declare const BT709_LIMITED: ChunkColour;
11
+ export interface ChunkStreamingOptions {
12
+ fps: Fps;
13
+ colour?: ChunkColour;
14
+ }
15
+ export declare function buildChunkStreamingArgs(options: ChunkStreamingOptions, outputPath: string): string[];
16
+ export declare function spawnChunkStreamingEncoder(outputPath: string, options: ChunkStreamingOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>): StreamingEncoder;
@@ -0,0 +1,30 @@
1
+ import { fpsToFfmpegArg } from "@hyperframes/core";
2
+ import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
3
+ import { spawnFfmpegStream } from "./streamingEncoder.js";
4
+ export const BT709_LIMITED = {
5
+ primaries: 1,
6
+ transfer: 1,
7
+ matrix: 1,
8
+ fullRange: false,
9
+ };
10
+ export function buildChunkStreamingArgs(options, outputPath) {
11
+ const { primaries, transfer, matrix, fullRange } = options.colour ?? BT709_LIMITED;
12
+ const vui = [
13
+ `colour_primaries=${primaries}`,
14
+ `transfer_characteristics=${transfer}`,
15
+ `matrix_coefficients=${matrix}`,
16
+ `video_full_range_flag=${fullRange ? 1 : 0}`,
17
+ ].join(":");
18
+ const args = ["-f", "h264", "-framerate", fpsToFfmpegArg(options.fps), "-i", "-"];
19
+ args.push("-c:v", "copy", "-bsf:v", `h264_metadata=${vui}`);
20
+ args.push(...["-colorspace:v", "bt709", "-color_primaries:v", "bt709", "-color_trc:v", "bt709"], ...["-color_range", fullRange ? "pc" : "tv"], ...["-video_track_timescale", "90000", "-avoid_negative_ts", "make_zero"]);
21
+ appendRenderProvenanceArgs(args, outputPath);
22
+ args.push("-y", outputPath);
23
+ return args;
24
+ }
25
+ export function spawnChunkStreamingEncoder(outputPath, options, signal, config) {
26
+ return spawnFfmpegStream(outputPath, buildChunkStreamingArgs(options, outputPath), {
27
+ signal,
28
+ config,
29
+ });
30
+ }
@@ -18,7 +18,7 @@ import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERT
18
18
  import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
19
19
  // ── Extracted modules ───────────────────────────────────────────────────────
20
20
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
21
- import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
21
+ import { beginFrameCapture, forgetBeginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
22
22
  import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, forcePreservedDrawingBuffer, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
23
23
  import { initThreeDProjection, scanCssEffects } from "./threeDProjection.js";
24
24
  import { planRiskFrames, scanCssEffectLiveFrames, riskSessionFits, withFramesAfterCallback, MAX_RISK_FRAME_FRACTION, } from "./riskFrames.js";
@@ -3299,6 +3299,7 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
3299
3299
  interval: session.beginFrameIntervalMs,
3300
3300
  noDisplayUpdates: false,
3301
3301
  });
3302
+ forgetBeginFrameCapture(page);
3302
3303
  session.beginFrameSeekPresented = true;
3303
3304
  }
3304
3305
  const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
@@ -0,0 +1,19 @@
1
+ export interface InPageEncodeInput {
2
+ enabled: boolean;
3
+ captureMode: "drawelement" | "screenshot" | "beginframe";
4
+ format: string;
5
+ codec: string;
6
+ hdr: boolean;
7
+ transparent: boolean;
8
+ workers: number;
9
+ hls: boolean;
10
+ videoLayer: boolean;
11
+ encoderSupported: boolean;
12
+ }
13
+ export type InPageEncodeDecision = {
14
+ eligible: true;
15
+ } | {
16
+ eligible: false;
17
+ reason: string;
18
+ };
19
+ export declare function resolveInPageEncode(input: InPageEncodeInput): InPageEncodeDecision;
@@ -0,0 +1,15 @@
1
+ export function resolveInPageEncode(input) {
2
+ const blocker = [
3
+ [!input.enabled, "disabled"],
4
+ [input.captureMode !== "drawelement", "capture is not drawElement"],
5
+ [input.format !== "mp4", "output is not mp4"],
6
+ [input.codec !== "h264", "codec is not h264"],
7
+ [input.hdr, "hdr"],
8
+ [input.transparent, "transparent"],
9
+ [input.workers !== 1, "parallel workers"],
10
+ [input.hls, "hls"],
11
+ [input.videoLayer, "video layer passthrough"],
12
+ [!input.encoderSupported, "page encoder unsupported"],
13
+ ].find(([blocked]) => blocked);
14
+ return blocker ? { eligible: false, reason: blocker[1] } : { eligible: true };
15
+ }
@@ -32,6 +32,7 @@ export interface BeginFrameResult {
32
32
  * the safe direction (screenshot capture always works).
33
33
  */
34
34
  export declare function probeBeginFrameLiveness(page: Page, timeoutMs: number, frameTimeTicks?: number, intervalMs?: number): Promise<boolean>;
35
+ export declare function forgetBeginFrameCapture(page: Page): void;
35
36
  export declare function beginFrameCapture(page: Page, options: CaptureOptions, frameTimeTicks: number, interval: number): Promise<BeginFrameResult>;
36
37
  /**
37
38
  * True if the page's actual rendered content is taller than the requested
@@ -81,11 +81,14 @@ frameTimeTicks, intervalMs) {
81
81
  * Requires chrome-headless-shell with --enable-begin-frame-control and
82
82
  * --deterministic-mode flags.
83
83
  */
84
- // Cache the last valid screenshot buffer per page for hasDamage=false frames.
85
- // When Chrome reports no visual change, we reuse the previous frame rather than
86
- // attempting Page.captureScreenshot (which times out in beginFrame mode since
87
- // the compositor is paused).
84
+ // The last buffer beginFrameCapture returned per page. A screenshot BeginFrame always
85
+ // reports damage, so a frame is reused only when a screenshot-less BeginFrame right after
86
+ // the seek reports none. Any BeginFrame sent elsewhere moves the display past this buffer:
87
+ // call forgetBeginFrameCapture first.
88
88
  const lastFrameCache = new WeakMap();
89
+ export function forgetBeginFrameCapture(page) {
90
+ lastFrameCache.delete(page);
91
+ }
89
92
  const PENDING_FRAME_RETRIES = 5;
90
93
  async function sendBeginFrame(client, params) {
91
94
  for (let attempt = 0;; attempt++) {
@@ -115,14 +118,30 @@ export async function beginFrameCapture(page, options, frameTimeTicks, interval)
115
118
  quality: isPng ? undefined : (options.quality ?? 80),
116
119
  optimizeForSpeed: true,
117
120
  };
118
- const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot });
121
+ // With a cached buffer, a screenshot-less BeginFrame first draws the seek and reports
122
+ // whether it changed anything. The screenshot after it is not displayed: a displayed
123
+ // screenshot frame leaves damage the next probe would report, and a settle frame to
124
+ // absorb it would draw page changes that nothing captures.
125
+ const cached = lastFrameCache.get(page);
126
+ let screenshotTicks = frameTimeTicks;
127
+ if (cached) {
128
+ const probe = await sendBeginFrame(client, { frameTimeTicks, interval });
129
+ if (!probe.hasDamage)
130
+ return { buffer: cached, hasDamage: false };
131
+ screenshotTicks = frameTimeTicks + interval / 2;
132
+ }
133
+ const result = await sendBeginFrame(client, {
134
+ frameTimeTicks: screenshotTicks,
135
+ interval,
136
+ screenshot,
137
+ noDisplayUpdates: cached !== undefined,
138
+ });
119
139
  let buffer;
120
140
  if (result.screenshotData) {
121
141
  buffer = Buffer.from(result.screenshotData, "base64");
122
142
  lastFrameCache.set(page, buffer);
123
143
  }
124
144
  else {
125
- const cached = lastFrameCache.get(page);
126
145
  if (cached) {
127
146
  buffer = cached;
128
147
  }
@@ -109,3 +109,8 @@ export declare function buildStreamingArgs(options: StreamingEncoderOptions, out
109
109
  * Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
110
110
  */
111
111
  export declare function spawnStreamingEncoder(outputPath: string, options: StreamingEncoderOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>): Promise<StreamingEncoder>;
112
+ export declare function spawnFfmpegStream(outputPath: string, args: string[], { signal, config, checkFrame, }: {
113
+ signal?: AbortSignal;
114
+ config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>;
115
+ checkFrame?: (frame: Buffer, index: number) => void;
116
+ }): StreamingEncoder;
@@ -302,15 +302,29 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null, nvenc
302
302
  * Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
303
303
  */
304
304
  export async function spawnStreamingEncoder(outputPath, options, signal, config) {
305
- const outputDir = dirname(outputPath);
306
- if (!existsSync(outputDir))
307
- mkdirSync(outputDir, { recursive: true });
308
305
  let gpuEncoder = null;
309
306
  if (options.useGpu) {
310
307
  gpuEncoder = await getCachedGpuEncoder();
311
308
  }
312
309
  const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
313
310
  const args = buildStreamingArgs(options, outputPath, gpuEncoder, nvencPresets);
311
+ const checksJpeg = !options.rawInputFormat && (options.imageFormat ?? "jpeg") === "jpeg";
312
+ return spawnFfmpegStream(outputPath, args, {
313
+ signal,
314
+ config,
315
+ checkFrame: checksJpeg
316
+ ? (frame, index) => {
317
+ const error = jpegInputError(frame);
318
+ if (error)
319
+ throw new Error(`Invalid JPEG input at frame ${index}: ${error}`);
320
+ }
321
+ : undefined,
322
+ });
323
+ }
324
+ export function spawnFfmpegStream(outputPath, args, { signal, config, checkFrame, }) {
325
+ const outputDir = dirname(outputPath);
326
+ if (!existsSync(outputDir))
327
+ mkdirSync(outputDir, { recursive: true });
314
328
  const ffmpeg = spawn(getFfmpegBinary(), args, {
315
329
  stdio: ["pipe", "pipe", "pipe"],
316
330
  // See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
@@ -412,11 +426,7 @@ export async function spawnStreamingEncoder(outputPath, options, signal, config)
412
426
  // so without this copy the pipe would read partially-overwritten data
413
427
  // and flicker.
414
428
  const copy = Buffer.from(buffer);
415
- if (!options.rawInputFormat && (options.imageFormat ?? "jpeg") === "jpeg") {
416
- const error = jpegInputError(copy);
417
- if (error)
418
- throw new Error(`Invalid JPEG input at frame ${inputFrameIndex}: ${error}`);
419
- }
429
+ checkFrame?.(copy, inputFrameIndex);
420
430
  const accepted = stdin.write(copy);
421
431
  inputFrameIndex++;
422
432
  // Reset inactivity timer immediately ONLY on `accepted === true`. `true`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,8 +59,8 @@
59
59
  "puppeteer": "~25.11.0",
60
60
  "puppeteer-core": "^25.11.0",
61
61
  "undici": "7.25.0",
62
- "@hyperframes/parsers": "^0.9.5",
63
- "@hyperframes/core": "^0.9.5"
62
+ "@hyperframes/core": "^0.9.6",
63
+ "@hyperframes/parsers": "^0.9.6"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^25.0.10",