@hyperframes/engine 0.9.4 → 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
@@ -29,6 +29,7 @@
29
29
  * getFrameAtTime, detectGpuEncoder) return a nullable value instead of throwing.
30
30
  *
31
31
  */
32
+ export { REFERENCE_BACKGROUND_FIELD, referenceBackgroundResult, createReferenceBackgroundRecorder, readReferenceBackground, type ReferenceBackground, type ReferenceBackgroundResult, } from "./services/referenceBackground.js";
32
33
  export type { HfProtocol, HfMediaElement, HfTransitionMeta, CaptureOptions, CaptureVideoMetadataHint, CaptureResult, CaptureBufferResult, CapturePerfSummary, CaptureWarning, CaptureWarningCode, SubTimelineWaitMemo, SubTimelineWaitOutcome, } from "./types.js";
33
34
  export { resolveConfig, resolvePageNavigationTimeout, validateEngineConfigSnapshot, DEFAULT_CONFIG, scaleProtocolTimeoutForComposition, shouldClampToScreenshotForConcreteGpu, applyConcreteGpuScreenshotClamp, explainDrawElementDisabled, resolveExtractCacheDir, defaultExtractCacheDir, EXTRACT_CACHE_DIR_DISABLED_ALIASES, type EngineConfig, type ExtractCacheDirResolution, } from "./config.js";
34
35
  export { SDR_RGB_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "./utils/sdrCaptureColor.js";
@@ -47,6 +48,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
47
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";
48
49
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
49
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";
50
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";
51
54
  export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
52
55
  export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@
29
29
  * getFrameAtTime, detectGpuEncoder) return a nullable value instead of throwing.
30
30
  *
31
31
  */
32
+ export { REFERENCE_BACKGROUND_FIELD, referenceBackgroundResult, createReferenceBackgroundRecorder, readReferenceBackground, } from "./services/referenceBackground.js";
32
33
  // ── Configuration ──────────────────────────────────────────────────────────────
33
34
  export { resolveConfig, resolvePageNavigationTimeout, validateEngineConfigSnapshot, DEFAULT_CONFIG, scaleProtocolTimeoutForComposition, shouldClampToScreenshotForConcreteGpu, applyConcreteGpuScreenshotClamp, explainDrawElementDisabled, resolveExtractCacheDir, defaultExtractCacheDir, EXTRACT_CACHE_DIR_DISABLED_ALIASES, } from "./config.js";
34
35
  export { SDR_RGB_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "./utils/sdrCaptureColor.js";
@@ -50,6 +51,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
50
51
  // ── Encoding ───────────────────────────────────────────────────────────────────
51
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";
52
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";
53
56
  // ── Media processing ───────────────────────────────────────────────────────────
54
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";
55
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
+ }
@@ -10,15 +10,15 @@
10
10
  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
+ import { readReferenceBackground } from "./referenceBackground.js";
13
14
  import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
14
- import { recordWebglContexts, releaseEndedWebglContexts } from "./webglRelease.js";
15
15
  import { asDrawElementCaptureFailure, DrawElementCaptureError } from "./drawElementCaptureError.js";
16
16
  import { encodePng } from "../utils/alphaBlit.js";
17
17
  import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
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";
@@ -448,6 +448,7 @@ async function waitForCloseWithTimeout(promise) {
448
448
  * path (preMode already forces "screenshot" for DPR other than 1).
449
449
  */
450
450
  async function initDrawElementOrTransparentBackground(session, page, logInitPhase) {
451
+ await session.options.recordReferenceBackground?.(() => readReferenceBackground(page));
451
452
  const supersampling = (session.options.deviceScaleFactor ?? 1) !== 1;
452
453
  // forceScreenshot is an explicit routing decision made upstream (render-mode
453
454
  // compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
@@ -1005,7 +1006,6 @@ async function constructCaptureSession(input) {
1005
1006
  }
1006
1007
  });
1007
1008
  await installMediaPageHooks(page, options);
1008
- await page.evaluateOnNewDocument(recordWebglContexts);
1009
1009
  // Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
1010
1010
  // preserveDrawingBuffer before any page script can create a context — their
1011
1011
  // paint records freeze at the first frame, so captureDrawElementFrame
@@ -1726,6 +1726,10 @@ export function recordSubTimelineWarning(session, timeoutMs) {
1726
1726
  },
1727
1727
  },
1728
1728
  ]);
1729
+ if (scriptFailure) {
1730
+ const failed = session.warnings.filter((w) => w.code === "sub_timeline_script_failure");
1731
+ session.options?.onSubTimelineScriptFailure?.(failed);
1732
+ }
1729
1733
  }
1730
1734
  /**
1731
1735
  * Runtime-mounted map-viewport markers, keyed by the CSS selector each map
@@ -2347,7 +2351,6 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
2347
2351
  const seekStart = Date.now();
2348
2352
  session.captureStep = "seeking the timeline";
2349
2353
  const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
2350
- await releaseEndedWebglContexts(page, quantizedTime);
2351
2354
  session.captureStep = "decoding CSS background images";
2352
2355
  await decodeDynamicCssBackgroundImages(page);
2353
2356
  const seekMs = Date.now() - seekStart;
@@ -3296,6 +3299,7 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
3296
3299
  interval: session.beginFrameIntervalMs,
3297
3300
  noDisplayUpdates: false,
3298
3301
  });
3302
+ forgetBeginFrameCapture(page);
3299
3303
  session.beginFrameSeekPresented = true;
3300
3304
  }
3301
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
+ }
@@ -0,0 +1,34 @@
1
+ import type { Page } from "puppeteer-core";
2
+ export declare const REFERENCE_BACKGROUND_FIELD = "reference_background";
3
+ export type ReferenceBackground = {
4
+ kind: "solid";
5
+ rgb: [number, number, number];
6
+ } | {
7
+ kind: "none";
8
+ } | {
9
+ kind: "unsupported_non_solid";
10
+ } | {
11
+ kind: "unknown";
12
+ };
13
+ export type ReferenceBackgroundResult = {
14
+ [REFERENCE_BACKGROUND_FIELD]: ReferenceBackground;
15
+ };
16
+ export declare function referenceBackgroundResult(value: unknown): ReferenceBackgroundResult;
17
+ /** Shared by a render's existing sessions; concurrent workers reserve the first read, not a chunk frame. */
18
+ export declare function createReferenceBackgroundRecorder(record: (result: ReferenceBackgroundResult) => void): (read: () => Promise<ReferenceBackground>) => Promise<void>;
19
+ export interface BackgroundLayer {
20
+ rgba: readonly number[];
21
+ image: string;
22
+ opacity: string;
23
+ filter: string;
24
+ blend: string;
25
+ varying: boolean;
26
+ }
27
+ export interface BackgroundObservation {
28
+ layers: readonly BackgroundLayer[];
29
+ explicitNone: boolean;
30
+ }
31
+ export declare function classifyReferenceBackground(observation: BackgroundObservation | null): ReferenceBackground;
32
+ /** Runs in the browser, before transparent capture rewrites the author's root/body styles. */
33
+ export declare function observeReferenceBackground(): Promise<BackgroundObservation | null>;
34
+ export declare function readReferenceBackground(page: Pick<Page, "evaluate">): Promise<ReferenceBackground>;
@@ -0,0 +1,175 @@
1
+ export const REFERENCE_BACKGROUND_FIELD = "reference_background";
2
+ function isByte(value) {
3
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255;
4
+ }
5
+ function readRgb(value) {
6
+ if (!Array.isArray(value) || value.length !== 3)
7
+ return null;
8
+ const [r, g, b] = value;
9
+ return isByte(r) && isByte(g) && isByte(b) ? [r, g, b] : null;
10
+ }
11
+ export function referenceBackgroundResult(value) {
12
+ if (value !== null && typeof value === "object" && "kind" in value) {
13
+ if (value.kind === "solid" && "rgb" in value) {
14
+ const rgb = readRgb(value.rgb);
15
+ if (rgb)
16
+ return { [REFERENCE_BACKGROUND_FIELD]: { kind: "solid", rgb } };
17
+ }
18
+ if (value.kind === "none" || value.kind === "unsupported_non_solid") {
19
+ return { [REFERENCE_BACKGROUND_FIELD]: { kind: value.kind } };
20
+ }
21
+ }
22
+ return { [REFERENCE_BACKGROUND_FIELD]: { kind: "unknown" } };
23
+ }
24
+ /** Shared by a render's existing sessions; concurrent workers reserve the first read, not a chunk frame. */
25
+ export function createReferenceBackgroundRecorder(record) {
26
+ let pending;
27
+ return (read) => {
28
+ pending ??= read()
29
+ .catch(() => ({ kind: "unknown" }))
30
+ .then((value) => record(referenceBackgroundResult(value)));
31
+ return pending;
32
+ };
33
+ }
34
+ function layerIsIndeterminate(layer) {
35
+ return (layer.varying || layer.opacity !== "1" || layer.filter !== "none" || layer.blend !== "normal");
36
+ }
37
+ function validLayer(layer) {
38
+ return layer.rgba.length === 4 && layer.rgba.every(isByte) && layer.image.length > 0;
39
+ }
40
+ function opaqueReference(rgb, indeterminate) {
41
+ return indeterminate ? { kind: "unknown" } : { kind: "solid", rgb };
42
+ }
43
+ export function classifyReferenceBackground(observation) {
44
+ if (!observation || observation.layers.length === 0)
45
+ return { kind: "unknown" };
46
+ const indeterminate = observation.layers.some(layerIsIndeterminate);
47
+ let translucent = false;
48
+ for (const layer of observation.layers) {
49
+ if (!validLayer(layer))
50
+ return { kind: "unknown" };
51
+ if (layer.image !== "none")
52
+ return { kind: "unsupported_non_solid" };
53
+ const rgb = layer.rgba[3] === 255 ? readRgb(layer.rgba.slice(0, 3)) : null;
54
+ if (rgb)
55
+ return opaqueReference(rgb, translucent || indeterminate);
56
+ translucent ||= layer.rgba[3] !== 0;
57
+ }
58
+ return !indeterminate && !translucent && observation.explicitNone
59
+ ? { kind: "none" }
60
+ : { kind: "unknown" };
61
+ }
62
+ /** Runs in the browser, before transparent capture rewrites the author's root/body styles. */
63
+ export async function observeReferenceBackground() {
64
+ const root = document.querySelector("[data-composition-id]");
65
+ if (!root || !document.body)
66
+ return null;
67
+ // Inspect the initial film state, never the worker's eventual frame range.
68
+ window.__hf?.seek?.(0);
69
+ if ("__hfWaitForSeekCompletion" in window &&
70
+ typeof window.__hfWaitForSeekCompletion === "function") {
71
+ await window.__hfWaitForSeekCompletion();
72
+ }
73
+ const elements = [];
74
+ for (let element = root; element; element = element.parentElement)
75
+ elements.push(element);
76
+ const canvas = document.createElement("canvas");
77
+ canvas.width = canvas.height = 1;
78
+ const context = canvas.getContext("2d", { willReadFrequently: true });
79
+ if (!context)
80
+ return null;
81
+ const layers = elements.map((element) => {
82
+ const style = getComputedStyle(element);
83
+ context.clearRect(0, 0, 1, 1);
84
+ if (!CSS.supports("color", style.backgroundColor))
85
+ return { rgba: [], image: "", opacity: "", filter: "", blend: "", varying: false };
86
+ // Chromium resolves CSS color syntax and color spaces; this is a swatch, not film pixels.
87
+ context.fillStyle = style.backgroundColor;
88
+ context.fillRect(0, 0, 1, 1);
89
+ return {
90
+ rgba: Array.from(context.getImageData(0, 0, 1, 1).data),
91
+ image: style.backgroundImage,
92
+ opacity: style.opacity,
93
+ filter: style.filter,
94
+ blend: style.mixBlendMode,
95
+ varying: false,
96
+ };
97
+ });
98
+ return { layers, explicitNone: false };
99
+ }
100
+ function hasChangingCssBackground() {
101
+ const root = document.querySelector("[data-composition-id]");
102
+ if (!root)
103
+ return true;
104
+ const properties = [
105
+ "background",
106
+ "backgroundColor",
107
+ "backgroundImage",
108
+ "opacity",
109
+ "filter",
110
+ "mixBlendMode",
111
+ ];
112
+ return document.getAnimations().some((animation) => {
113
+ const effect = animation.effect;
114
+ if (!(effect instanceof KeyframeEffect) || !(effect.target instanceof Element))
115
+ return false;
116
+ return (effect.target.contains(root) &&
117
+ effect.getKeyframes().some((frame) => properties.some((key) => key in frame)));
118
+ });
119
+ }
120
+ function hasChangingTimelineBackground() {
121
+ const root = document.querySelector("[data-composition-id]");
122
+ const timelines = "__timelines" in window ? window.__timelines : undefined;
123
+ if (!root || !timelines || typeof timelines !== "object")
124
+ return false;
125
+ const children = Object.values(timelines).flatMap((timeline) => typeof timeline?.getChildren === "function" ? timeline.getChildren(true, true, false) : []);
126
+ return children.some((tween) => {
127
+ const vars = { ...tween?.vars, ...tween?.vars?.css, ...tween?.vars?.startAt };
128
+ const targets = typeof tween?.targets === "function" ? tween.targets() : [];
129
+ const changesPaint = Object.keys(vars).some((key) => key.startsWith("background") ||
130
+ ["opacity", "filter", "autoAlpha", "mixBlendMode"].includes(key));
131
+ return (changesPaint &&
132
+ targets.some((target) => target instanceof Element && target.contains(root)));
133
+ });
134
+ }
135
+ function hasExplicitNoBackground() {
136
+ const root = document.querySelector("[data-composition-id]");
137
+ const elements = [];
138
+ for (let element = root; element; element = element.parentElement)
139
+ elements.push(element);
140
+ const declarations = elements.flatMap((element) => element instanceof HTMLElement || element instanceof SVGElement ? [element.style] : []);
141
+ const sheets = [...document.styleSheets, ...document.adoptedStyleSheets].filter((sheet) => !sheet.disabled);
142
+ for (const rule of sheets.flatMap((sheet) => Array.from(sheet.cssRules))) {
143
+ if (rule instanceof CSSFontFaceRule || rule instanceof CSSKeyframesRule)
144
+ continue;
145
+ // Proving absence needs complete cascade participation. Unhandled grouping/nesting stays unknown.
146
+ if (!(rule instanceof CSSStyleRule) || rule.cssRules.length > 0)
147
+ return false;
148
+ if (elements.some((element) => element.matches(rule.selectorText)))
149
+ declarations.push(rule.style);
150
+ }
151
+ const backgrounds = declarations.filter((style) => style.getPropertyValue("background-color") || style.getPropertyValue("background-image"));
152
+ return (backgrounds.length > 0 &&
153
+ backgrounds.every((style) => style.getPropertyValue("background").trim() === "none"));
154
+ }
155
+ export async function readReferenceBackground(page) {
156
+ try {
157
+ const observation = await page.evaluate(observeReferenceBackground);
158
+ const initial = classifyReferenceBackground(observation);
159
+ if (!observation || initial.kind === "unsupported_non_solid")
160
+ return initial;
161
+ const changes = await Promise.all([
162
+ page.evaluate(hasChangingCssBackground),
163
+ page.evaluate(hasChangingTimelineBackground),
164
+ ]);
165
+ if (changes.some(Boolean))
166
+ return { kind: "unknown" };
167
+ if (initial.kind === "solid")
168
+ return initial;
169
+ observation.explicitNone = await page.evaluate(hasExplicitNoBackground);
170
+ return classifyReferenceBackground(observation);
171
+ }
172
+ catch {
173
+ return { kind: "unknown" };
174
+ }
175
+ }
@@ -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/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * The engine's page contract. Any web page that wants to be rendered
5
5
  * as video must expose `window.__hf` implementing the HfProtocol interface.
6
6
  */
7
+ import type { ReferenceBackground } from "./services/referenceBackground.js";
7
8
  import type { AudioFailureDiagnostic } from "./services/audioMixer.types.js";
8
9
  import type { Fps } from "@hyperframes/core";
9
10
  import type { CompositionLengthSource } from "@hyperframes/core/runtime/composition-length";
@@ -129,8 +130,11 @@ export interface HfProtocol {
129
130
  } | null;
130
131
  }
131
132
  export interface CaptureOptions {
133
+ /** Shared once-only reader, invoked before capture changes root/body paint. */
134
+ recordReferenceBackground?: (read: () => Promise<ReferenceBackground>) => Promise<void>;
132
135
  width: number;
133
136
  height: number;
137
+ onSubTimelineScriptFailure?: (warnings: readonly CaptureWarning[]) => void;
134
138
  /**
135
139
  * Producer-resolved composition duration (seconds) — the data-duration
136
140
  * clamp actually rendered, which can differ from the page's raw
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.9.4",
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/core": "^0.9.4",
63
- "@hyperframes/parsers": "^0.9.4"
62
+ "@hyperframes/core": "^0.9.6",
63
+ "@hyperframes/parsers": "^0.9.6"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^25.0.10",