@hyperframes/engine 0.9.4 → 0.9.5

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
@@ -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";
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";
@@ -10,8 +10,8 @@
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";
@@ -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;
@@ -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
+ }
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.5",
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/parsers": "^0.9.5",
63
+ "@hyperframes/core": "^0.9.5"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^25.0.10",