@hyperframes/engine 0.8.199 → 0.8.201

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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Which frames drawElement capture cannot reproduce, so those frames alone are captured by
3
+ * screenshot and the rest keep the fast path.
4
+ */
5
+ import { quantizeTimeToFrame } from "@hyperframes/core";
6
+ /** Frames per page round trip while scanning; a scan is one in-page loop per chunk. */
7
+ const SCAN_CHUNK_FRAMES = 240;
8
+ /**
9
+ * Above this share of risk frames the second browser and the switching cost outweigh the fast
10
+ * frames, so the whole render captures by screenshot as it always has.
11
+ */
12
+ export const MAX_RISK_FRAME_FRACTION = 0.6;
13
+ /** Peak memory per-frame capture adds over a screenshot render at 1080p: 1002 to 2001 MB measured. */
14
+ export const RISK_RENDER_ADDED_BYTES = 1_000_000_000;
15
+ /** Memory that must still be available once the screenshot browser is open. */
16
+ export const RISK_RENDER_HEADROOM_BYTES = 1_500_000_000;
17
+ const PIXELS_1080P = 1920 * 1080;
18
+ /** Risk ranges closer than this many frames are joined, so capture never alternates modes. */
19
+ export const RISK_RANGE_HYSTERESIS_FRAMES = 12;
20
+ /**
21
+ * Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
22
+ * Null once more than `maxFrames` frames qualify.
23
+ */
24
+ export async function scanCssEffectLiveFrames(page, fps, totalFrames, maxFrames) {
25
+ const live = new Set();
26
+ for (let from = 0; from < totalFrames; from += SCAN_CHUNK_FRAMES) {
27
+ const times = [];
28
+ for (let f = from; f < Math.min(totalFrames, from + SCAN_CHUNK_FRAMES); f++) {
29
+ times.push(quantizeTimeToFrame(f / fps, fps));
30
+ }
31
+ const hits = await page.evaluate(async (ts) => {
32
+ const w = window;
33
+ const targets = w.__hfCssFxTargets ?? [];
34
+ // A hidden element still shows a descendant that sets `visibility: visible` itself.
35
+ const paints = (el) => getComputedStyle(el).visibility !== "hidden" ||
36
+ Array.from(el.querySelectorAll("*")).some((d) => getComputedStyle(d).visibility === "visible");
37
+ const shows = (el) => {
38
+ for (let n = el; n; n = n.parentElement) {
39
+ const cs = getComputedStyle(n);
40
+ if (cs.display === "none" || Number(cs.opacity) === 0)
41
+ return false;
42
+ }
43
+ return paints(el);
44
+ };
45
+ const out = [];
46
+ for (let i = 0; i < ts.length; i++) {
47
+ w.__hf?.seek?.(ts[i]);
48
+ await w.__hfWaitForSeekCompletion?.();
49
+ if (targets.some(shows))
50
+ out.push(i);
51
+ }
52
+ return out;
53
+ }, times);
54
+ for (const i of hits)
55
+ live.add(from + i);
56
+ if (live.size > maxFrames)
57
+ break;
58
+ }
59
+ return live.size > maxFrames ? null : live;
60
+ }
61
+ /**
62
+ * The given frames with a one-frame margin, ranges closer than {@link RISK_RANGE_HYSTERESIS_FRAMES}
63
+ * joined. Null when they cover more than {@link MAX_RISK_FRAME_FRACTION} of the render.
64
+ */
65
+ export function planRiskFrames(frames, totalFrames) {
66
+ const sorted = [...new Set(frames)].sort((a, b) => a - b);
67
+ const plan = new Set();
68
+ let rangeStart = -1;
69
+ let rangeEnd = -1;
70
+ const flush = () => {
71
+ for (let f = Math.max(0, rangeStart - 1); f <= Math.min(totalFrames - 1, rangeEnd + 1); f++) {
72
+ plan.add(f);
73
+ }
74
+ };
75
+ for (const f of sorted) {
76
+ if (rangeStart >= 0 && f - rangeEnd <= RISK_RANGE_HYSTERESIS_FRAMES) {
77
+ rangeEnd = f;
78
+ continue;
79
+ }
80
+ if (rangeStart >= 0)
81
+ flush();
82
+ rangeStart = f;
83
+ rangeEnd = f;
84
+ }
85
+ if (rangeStart >= 0)
86
+ flush();
87
+ return plan.size > totalFrames * MAX_RISK_FRAME_FRACTION ? null : plan;
88
+ }
89
+ /** `live` plus every frame from just before the first callback to the end of the render. */
90
+ export function withFramesAfterCallback(live, callbackFrom, fps, totalFrames) {
91
+ if (callbackFrom === null)
92
+ return live;
93
+ for (let f = Math.max(0, Math.floor(callbackFrom * fps) - 1); f < totalFrames; f++)
94
+ live.add(f);
95
+ return live;
96
+ }
97
+ /** Whether the render fits: what it adds, scaled by output pixels, leaves the headroom. */
98
+ export function riskSessionFits(availableMb, outputPixels) {
99
+ const rss = RISK_RENDER_ADDED_BYTES * Math.max(1, outputPixels / PIXELS_1080P);
100
+ return availableMb * 1024 * 1024 - rss >= RISK_RENDER_HEADROOM_BYTES;
101
+ }
@@ -73,6 +73,13 @@ export declare function pageScreenshotCapture(page: Page, options: CaptureOption
73
73
  * round-trip overhead.
74
74
  */
75
75
  export declare function captureScreenshotWithAlpha(page: Page, width: number, height: number): Promise<Buffer>;
76
+ /**
77
+ * Make the page transparent for alpha capture, then use captureAlphaPng() per frame. html/body always clear;
78
+ * [data-composition-id] roots (nested too) keep their background unless clearCompositionRoot (HDR layered pass).
79
+ * A repeat call rewrites the rule, so the last call wins.
80
+ */
81
+ /** Whether frames must carry alpha. PNG alone does not imply it: motion blur captures opaque output as PNG. */
82
+ export declare function capturesAlpha(options: Pick<CaptureOptions, "format" | "transparent">): boolean;
76
83
  export declare function initTransparentBackground(page: Page, { clearCompositionRoot }?: {
77
84
  clearCompositionRoot?: boolean;
78
85
  }): Promise<void>;
@@ -234,6 +234,10 @@ export async function captureScreenshotWithAlpha(page, width, height) {
234
234
  * [data-composition-id] roots (nested too) keep their background unless clearCompositionRoot (HDR layered pass).
235
235
  * A repeat call rewrites the rule, so the last call wins.
236
236
  */
237
+ /** Whether frames must carry alpha. PNG alone does not imply it: motion blur captures opaque output as PNG. */
238
+ export function capturesAlpha(options) {
239
+ return options.transparent ?? options.format === "png";
240
+ }
237
241
  const TRANSPARENT_BG_STYLE_ID = "__hf_transparent_bg__";
238
242
  export async function initTransparentBackground(page, { clearCompositionRoot = false } = {}) {
239
243
  const client = await getCdpSession(page);
@@ -49,3 +49,5 @@ export declare const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
49
49
  * small VMs, small Lambda tiers, small containers) are detected correctly.
50
50
  */
51
51
  export declare function isLowMemorySystem(totalMb?: number): boolean;
52
+ /** Memory available in MiB: on macOS and Linux, counting pages the OS reclaims on demand. */
53
+ export declare function getAvailableMemoryMb(): number;
@@ -7,8 +7,9 @@
7
7
  * They all need the same "how much memory does this box have" reading, so
8
8
  * it lives here once instead of being re-derived inline.
9
9
  */
10
+ import { execSync } from "child_process";
10
11
  import { readFileSync } from "fs";
11
- import { totalmem } from "os";
12
+ import { freemem, platform, totalmem } from "os";
12
13
  const BYTES_PER_MIB = 1024 * 1024;
13
14
  const BYTES_PER_MIB_BIGINT = BigInt(BYTES_PER_MIB);
14
15
  // These are the paths as seen from INSIDE a container, where the runtime
@@ -154,3 +155,39 @@ export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
154
155
  export function isLowMemorySystem(totalMb = getSystemTotalMb()) {
155
156
  return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
156
157
  }
158
+ /** Memory available in MiB: on macOS and Linux, counting pages the OS reclaims on demand. */
159
+ export function getAvailableMemoryMb() {
160
+ const fallback = Math.trunc(freemem() / BYTES_PER_MIB);
161
+ if (platform() === "darwin") {
162
+ try {
163
+ const raw = execSync("vm_stat", { encoding: "utf-8", timeout: 5000 });
164
+ const pageSize = parseInt(raw.match(/page size of (\d+)/)?.[1] ?? "0", 10);
165
+ if (!pageSize)
166
+ return fallback;
167
+ const pages = (key) => parseInt(raw.match(new RegExp(`${key}:\\s+(\\d+)`))?.[1] ?? "0", 10);
168
+ const available = (pages("Pages free") +
169
+ pages("Pages inactive") +
170
+ pages("Pages purgeable") +
171
+ pages("Pages speculative")) *
172
+ pageSize;
173
+ return available > 0 ? Math.trunc(available / BYTES_PER_MIB) : fallback;
174
+ }
175
+ catch {
176
+ return fallback;
177
+ }
178
+ }
179
+ if (platform() === "linux") {
180
+ try {
181
+ const meminfo = readFileSync("/proc/meminfo", "utf-8");
182
+ const match = meminfo.match(/MemAvailable:\s+(\d+)\s+kB/);
183
+ if (match) {
184
+ return Math.trunc(parseInt(match[1], 10) / 1024);
185
+ }
186
+ return fallback;
187
+ }
188
+ catch {
189
+ return fallback;
190
+ }
191
+ }
192
+ return fallback;
193
+ }
@@ -77,3 +77,17 @@ export declare function initThreeDProjection(page: Page): Promise<ThreeDProjecti
77
77
  * Returns the first matched effect name (for logging) or null.
78
78
  */
79
79
  export declare function detectCssEffectRisk(page: Page): Promise<string | null>;
80
+ /** What {@link scanCssEffects} found. */
81
+ export interface CssEffectScan {
82
+ /** First effect matched, in scan order (computed, stylesheet, tween vars, WebGL), or null. */
83
+ reason: string | null;
84
+ /** False when a match is not tied to an element (unmatched rule, CSS animation, WebGL). */
85
+ bounded: boolean;
86
+ /** Seconds at which the first timeline callback can fire; the effect can show anywhere after. */
87
+ callbackFrom: number | null;
88
+ }
89
+ /**
90
+ * Which CSS effects drawElementImage cannot reproduce. `collect: true` scans past the first match
91
+ * and leaves the elements carrying an effect in `window.__hfCssFxTargets`.
92
+ */
93
+ export declare function scanCssEffects(page: Page, collect: boolean): Promise<CssEffectScan>;
@@ -864,6 +864,13 @@ export async function initThreeDProjection(page) {
864
864
  * Returns the first matched effect name (for logging) or null.
865
865
  */
866
866
  export async function detectCssEffectRisk(page) {
867
+ return (await scanCssEffects(page, false)).reason;
868
+ }
869
+ /**
870
+ * Which CSS effects drawElementImage cannot reproduce. `collect: true` scans past the first match
871
+ * and leaves the elements carrying an effect in `window.__hfCssFxTargets`.
872
+ */
873
+ export async function scanCssEffects(page, collect) {
867
874
  try {
868
875
  // MUST NOT seek the timeline here. Driving `window.__hf.seek` to sample
869
876
  // frames renders GSAP `.from()` / overlapping tweens out of forward order and
@@ -874,10 +881,23 @@ export async function detectCssEffectRisk(page) {
874
881
  // 1e5a1165: the Pong scene's scale/rotation tween lands wrong, 19.8 dB / 27
875
882
  // damaged frames; the fix restored it to baseline parity, 43 dB / 0). So we
876
883
  // detect the same effects seek-free, four ways:
877
- return await page.evaluate(() => {
884
+ return await page.evaluate((collectAll) => {
878
885
  const root = document.querySelector("[data-composition-id]");
879
886
  if (!root)
880
- return null;
887
+ return { reason: null, bounded: true, callbackFrom: null };
888
+ let reason = null;
889
+ let bounded = true;
890
+ const targets = new Set();
891
+ // A match on `els` (null: not tied to any element). Returns true to stop the scan.
892
+ const hit = (name, els) => {
893
+ reason = reason ?? name;
894
+ if (els && els.length > 0)
895
+ for (const el of Array.from(els))
896
+ targets.add(el);
897
+ else
898
+ bounded = false;
899
+ return !collectAll;
900
+ };
881
901
  // (1) Computed styles at the current (init/t0) state — effects present in
882
902
  // the base render.
883
903
  const scanComputed = () => {
@@ -885,28 +905,51 @@ export async function detectCssEffectRisk(page) {
885
905
  for (const el of els) {
886
906
  const cs = getComputedStyle(el);
887
907
  const bf = cs.backdropFilter || cs.webkitBackdropFilter || "";
888
- if (bf && bf !== "none")
889
- return "backdrop-filter";
908
+ if (bf && bf !== "none" && hit("backdrop-filter", [el]))
909
+ return true;
890
910
  const f = cs.filter || "";
891
- if (f && f !== "none" && f.indexOf("blur(") !== -1)
892
- return "filter:blur";
893
- if (f && f !== "none" && f.indexOf("drop-shadow(") !== -1)
894
- return "filter:drop-shadow";
911
+ if (f && f !== "none" && f.indexOf("blur(") !== -1 && hit("filter:blur", [el])) {
912
+ return true;
913
+ }
914
+ if (f &&
915
+ f !== "none" &&
916
+ f.indexOf("drop-shadow(") !== -1 &&
917
+ hit("filter:drop-shadow", [el])) {
918
+ return true;
919
+ }
895
920
  const mbm = cs.mixBlendMode || "";
896
- if (mbm && mbm !== "normal")
897
- return "mix-blend-mode";
921
+ if (mbm && mbm !== "normal" && hit("mix-blend-mode", [el]))
922
+ return true;
898
923
  const an = cs.animationName || "";
899
924
  const ad = cs.animationDuration || "0s";
900
- if (an && an !== "none" && ad !== "0s" && parseFloat(ad) > 0)
901
- return "css-animation";
925
+ if (an &&
926
+ an !== "none" &&
927
+ ad !== "0s" &&
928
+ parseFloat(ad) > 0 &&
929
+ hit("css-animation", null)) {
930
+ return true;
931
+ }
902
932
  }
903
- return null;
933
+ return false;
904
934
  };
905
935
  // (2) Stylesheet rules — an effect applied later via a class swap won't be
906
936
  // computed at t0, but the rule that declares it exists up front. This
907
937
  // replaces the old per-frame scrub scan seek-free (conservative: a declared
908
938
  // rule ⇒ gate, even if applied only mid-timeline).
909
939
  const scanStyleSheets = () => {
940
+ // The elements a style rule can apply to now; a pseudo-element's effect shows only while
941
+ // its owner does. Empty when the rule is not a plain style rule or matches nothing yet.
942
+ const ruleTargets = (rule) => {
943
+ const selector = rule.selectorText;
944
+ if (!selector)
945
+ return [];
946
+ try {
947
+ return Array.from(root.querySelectorAll(selector.replace(/::?(before|after|first-line|first-letter|marker|placeholder|selection|backdrop)/g, "")));
948
+ }
949
+ catch {
950
+ return [];
951
+ }
952
+ };
910
953
  for (const sheet of Array.from(document.styleSheets)) {
911
954
  let rules = null;
912
955
  try {
@@ -917,63 +960,96 @@ export async function detectCssEffectRisk(page) {
917
960
  }
918
961
  for (const rule of Array.from(rules ?? [])) {
919
962
  const txt = rule.cssText || "";
963
+ let name = null;
920
964
  if (/backdrop-filter\s*:\s*(?!none)/i.test(txt))
921
- return "backdrop-filter";
922
- if (/(?:^|[^-])filter\s*:[^;{}]*blur\(/i.test(txt))
923
- return "filter:blur";
924
- if (/(?:^|[^-])filter\s*:[^;{}]*drop-shadow\(/i.test(txt)) {
925
- return "filter:drop-shadow";
965
+ name = "backdrop-filter";
966
+ else if (/(?:^|[^-])filter\s*:[^;{}]*blur\(/i.test(txt))
967
+ name = "filter:blur";
968
+ else if (/(?:^|[^-])filter\s*:[^;{}]*drop-shadow\(/i.test(txt)) {
969
+ name = "filter:drop-shadow";
926
970
  }
927
- if (/mix-blend-mode\s*:\s*(?!normal)/i.test(txt))
928
- return "mix-blend-mode";
971
+ else if (/mix-blend-mode\s*:\s*(?!normal)/i.test(txt))
972
+ name = "mix-blend-mode";
973
+ if (name && hit(name, ruleTargets(rule)))
974
+ return true;
929
975
  }
930
976
  }
931
- return null;
977
+ return false;
932
978
  };
933
979
  // (3) GSAP tween vars — effects animated by GSAP writing inline
934
980
  // filter / backdropFilter / mixBlendMode (never present in a stylesheet).
935
981
  const scanTweenVars = () => {
936
982
  const walk = (tl) => {
937
983
  if (typeof tl.getChildren !== "function")
938
- return null;
984
+ return false;
939
985
  for (const c of tl.getChildren(false, true, true)) {
940
- const sub = walk(c);
941
- if (sub)
942
- return sub;
986
+ if (walk(c))
987
+ return true;
943
988
  const vars = c.vars || {};
944
989
  for (const k of Object.keys(vars)) {
945
990
  const kl = k.toLowerCase();
991
+ let name = null;
946
992
  if (kl.includes("blend"))
947
- return "mix-blend-mode";
948
- if (kl.includes("backdrop"))
949
- return "backdrop-filter";
950
- if (kl === "filter" && /blur\(/i.test(String(vars[k] ?? "")))
951
- return "filter:blur";
952
- if (kl === "filter" && /drop-shadow\(/i.test(String(vars[k] ?? ""))) {
953
- return "filter:drop-shadow";
993
+ name = "mix-blend-mode";
994
+ else if (kl.includes("backdrop"))
995
+ name = "backdrop-filter";
996
+ else if (kl === "filter" && /blur\(/i.test(String(vars[k] ?? ""))) {
997
+ name = "filter:blur";
954
998
  }
999
+ else if (kl === "filter" && /drop-shadow\(/i.test(String(vars[k] ?? ""))) {
1000
+ name = "filter:drop-shadow";
1001
+ }
1002
+ if (!name)
1003
+ continue;
1004
+ const els = (typeof c.targets === "function" ? c.targets() : []).filter((t) => t instanceof Element);
1005
+ if (hit(name, els))
1006
+ return true;
955
1007
  }
956
1008
  }
957
- return null;
1009
+ return false;
958
1010
  };
959
1011
  const tls = window.__timelines || {};
960
1012
  for (const tl of Object.values(tls)) {
961
- const r = walk(tl);
962
- if (r)
963
- return r;
1013
+ if (walk(tl))
1014
+ return true;
964
1015
  }
965
- return null;
1016
+ return false;
966
1017
  };
967
1018
  // (4) WebGL context — seek-invariant, recorded at context creation.
968
1019
  const scanWebgl = () => {
969
1020
  const aw = window;
970
1021
  const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
971
- return accel.length > 0 ? "webgl-context" : null;
1022
+ return accel.length > 0 && hit("webgl-context", null);
972
1023
  };
973
- return scanComputed() || scanStyleSheets() || scanTweenVars() || scanWebgl();
974
- });
1024
+ for (const scan of [scanComputed, scanStyleSheets, scanTweenVars, scanWebgl]) {
1025
+ if (scan())
1026
+ break;
1027
+ }
1028
+ const calls = ["onStart", "onUpdate", "onComplete", "onRepeat", "onReverseComplete"];
1029
+ const firstCallback = (node, offset) => {
1030
+ const start = offset + (typeof node.startTime === "function" ? node.startTime() : 0);
1031
+ let first = calls.some((k) => typeof node.vars?.[k] === "function") ? start : Infinity;
1032
+ if (typeof node.getChildren === "function") {
1033
+ for (const child of node.getChildren(false, true, true)) {
1034
+ first = Math.min(first, firstCallback(child, start));
1035
+ }
1036
+ }
1037
+ return first;
1038
+ };
1039
+ const timelines = window
1040
+ .__timelines;
1041
+ const callbackStart = reason
1042
+ ? Math.min(Infinity, ...Object.values(timelines ?? {}).map((tl) => firstCallback(tl, 0)))
1043
+ : Infinity;
1044
+ window.__hfCssFxTargets = [...targets];
1045
+ return {
1046
+ reason,
1047
+ bounded,
1048
+ callbackFrom: Number.isFinite(callbackStart) ? callbackStart : null,
1049
+ };
1050
+ }, collect);
975
1051
  }
976
1052
  catch {
977
- return null;
1053
+ return { reason: null, bounded: true, callbackFrom: null };
978
1054
  }
979
1055
  }
@@ -23,7 +23,7 @@ import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
23
23
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
24
24
  import { downloadToTemp, isHttpUrl, safeDownloadUrlIdentity, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
25
25
  import { runFfmpeg } from "../utils/runFfmpeg.js";
26
- import { isFfmpegFilterAvailable } from "../utils/psnrFilterAvailability.js";
26
+ import { isFfmpegFilterAvailable, isFfmpegHwaccelAvailable, } from "../utils/psnrFilterAvailability.js";
27
27
  import { DEFAULT_CONFIG } from "../config.js";
28
28
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
29
29
  import { FRAME_FILENAME_PREFIX, gcExtractionCache, gcSweepDue, lookupCacheEntry, partialCacheEntryDir, publishCacheEntry, readContentSha256, readKeyStat, rehydrateCacheEntry, touchCacheEntry, } from "./extractionCache.js";
@@ -606,7 +606,12 @@ outputDirOverride) {
606
606
  const isHdr = isHdrColorSpaceUtil(metadata.colorSpace);
607
607
  const isMacOS = process.platform === "darwin";
608
608
  const toneMappedToSdr = options.toneMapHdrToSdr === true && isHdr && (await toneMapsWithZscale());
609
- const decodeWithVideoToolbox = isHdr && isMacOS && !toneMappedToSdr;
609
+ const keepsHdrForChrome = isHdr && isMacOS && !toneMappedToSdr;
610
+ const decodeWithVideoToolbox = keepsHdrForChrome ||
611
+ (isMacOS &&
612
+ !metadata.hasAlpha &&
613
+ !codecMayHaveAlpha(metadata.videoCodec) &&
614
+ (await isFfmpegHwaccelAvailable("videotoolbox")));
610
615
  const sampleAtOutputFps = !options.finalFrameOnly;
611
616
  if (options.frameRanges && (!sampleAtOutputFps || metadata.isVFR)) {
612
617
  throw new Error("frameRanges extraction needs a constant-frame-rate source");
@@ -642,7 +647,7 @@ outputDirOverride) {
642
647
  args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
643
648
  }
644
649
  const vfFilters = [];
645
- if (decodeWithVideoToolbox) {
650
+ if (keepsHdrForChrome) {
646
651
  vfFilters.push("format=nv12");
647
652
  }
648
653
  const samplingAt = vfFilters.length;
@@ -685,8 +690,7 @@ outputDirOverride) {
685
690
  if (format === "png")
686
691
  encodeArgs.push("-compression_level", "1");
687
692
  encodeArgs.push("-y", outputPattern);
688
- // A long 4K run can take far longer than the timeout and still be healthy; only a stall is a hang.
689
- const runOptions = { signal, stallTimeout: ffmpegProcessTimeout };
693
+ const runOptions = { signal, timeout: ffmpegProcessTimeout };
690
694
  const filterArgsFor = (seek) => {
691
695
  const filters = [...vfFilters];
692
696
  filters.splice(samplingAt, 0, ...samplingFilters(seek));
@@ -148,7 +148,7 @@ export function createVideoFrameInjector(frameLookup, config) {
148
148
  lastCacheTouchByDir.set(cacheDir, now);
149
149
  }
150
150
  // fallow-ignore-next-line complexity
151
- return async (page, time, heldVideoTime) => {
151
+ const inject = async (page, time, heldVideoTime) => {
152
152
  for (const cacheDir of frameLookup.frameDirs())
153
153
  renewCacheLease(cacheDir);
154
154
  const activePayloads = frameLookup.getActiveFramePayloads(time);
@@ -205,6 +205,7 @@ export function createVideoFrameInjector(frameLookup, config) {
205
205
  }
206
206
  }
207
207
  };
208
+ return Object.assign(inject, { fresh: () => createVideoFrameInjector(frameLookup, config) });
208
209
  }
209
210
  /**
210
211
  * Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so
package/dist/types.d.ts CHANGED
@@ -16,11 +16,12 @@ export type PageMotionBlurOff = "off" | "hdr-render" | "layered-composite" | "di
16
16
  */
17
17
  export type SubTimelineWaitOutcome = "ready" | "timeout" | "script_failure";
18
18
  /**
19
- * Shared by every capture session of one render: composition ids whose timeline
20
- * wait already timed out, so later sessions report the timeout without waiting again.
19
+ * Shared by every capture session of one render: timeline waits that already timed out
20
+ * (so later sessions skip the wait), and scene callback errors the render log already shows.
21
21
  */
22
22
  export interface SubTimelineWaitMemo {
23
23
  unregisteredIds?: readonly string[];
24
+ loggedSceneCallbackErrors?: string[];
24
25
  }
25
26
  export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
26
27
  /** Structured correctness warning produced while preparing a capture session. */
@@ -149,6 +150,7 @@ export interface CaptureOptions {
149
150
  */
150
151
  fps: Fps;
151
152
  format?: "jpeg" | "png";
153
+ transparent?: boolean;
152
154
  quality?: number;
153
155
  /**
154
156
  * Opt into sub-frame multi-sample motion blur (issue #4010). Absent means off and the
@@ -221,6 +223,7 @@ export interface CaptureOptions {
221
223
  * Clamped to 0..8 like the env knob; HF_DE_VERIFY, when set, still wins.
222
224
  */
223
225
  deVerifySamples?: number;
226
+ deFrameFallback?: boolean;
224
227
  }
225
228
  export interface CaptureVideoMetadataHint {
226
229
  id: string;
@@ -382,6 +385,7 @@ export interface CapturePerfSummary {
382
385
  deVerifyInitMs: number;
383
386
  /** Clip-cut boundary frames routed to per-frame screenshot (Lim 6). */
384
387
  deBoundaryFrames: number;
388
+ deRiskFrames: number;
385
389
  /** Per-frame "No cached paint record" screenshot fallbacks during capture. */
386
390
  deNcprFallbacks: number;
387
391
  /**
@@ -9,5 +9,6 @@
9
9
  */
10
10
  export declare function isPsnrFilterAvailable(): Promise<boolean>;
11
11
  export declare function isFfmpegFilterAvailable(name: string): Promise<boolean>;
12
+ export declare function isFfmpegHwaccelAvailable(name: string): Promise<boolean>;
12
13
  /** Test-only: drop the memoized probe result. */
13
14
  export declare function resetPsnrFilterAvailabilityCache(): void;
@@ -14,7 +14,14 @@ import { getFfmpegBinary } from "./ffmpegBinaries.js";
14
14
  * not change across renders within the same CLI invocation, and re-probing
15
15
  * per session would burn ~50-100ms of subprocess spawn per capture worker.
16
16
  */
17
- let cachedFilterList = null;
17
+ const cachedLists = new Map();
18
+ function listed(flag) {
19
+ let list = cachedLists.get(flag);
20
+ if (!list)
21
+ cachedLists.set(flag, (list = listFfmpeg(flag)));
22
+ return list;
23
+ }
24
+ const listsWord = (stdout, name) => stdout !== null && new RegExp(`(^|\\s)${name}(\\s|$)`, "m").test(stdout);
18
25
  /**
19
26
  * Returns true when the resident ffmpeg exposes the `psnr` filter. False on
20
27
  * any probe failure — missing binary (ENOENT), non-zero exit, timeout,
@@ -27,25 +34,25 @@ let cachedFilterList = null;
27
34
  export function isPsnrFilterAvailable() {
28
35
  return isFfmpegFilterAvailable("psnr");
29
36
  }
37
+ // `-filters` and `-hwaccels` list one name per line; a whole-word match ignores longer names
38
+ // and banner prose that mentions it.
30
39
  export async function isFfmpegFilterAvailable(name) {
31
- if (cachedFilterList === null)
32
- cachedFilterList = listFilters();
33
- const stdout = await cachedFilterList;
34
- // `-filters` lists one filter per line; a whole-word match ignores longer names
35
- // and banner prose that mentions the filter.
36
- return stdout !== null && new RegExp(`(^|\\s)${name}(\\s|$)`, "m").test(stdout);
40
+ return listsWord(await listed("-filters"), name);
41
+ }
42
+ export async function isFfmpegHwaccelAvailable(name) {
43
+ return listsWord(await listed("-hwaccels"), name);
37
44
  }
38
45
  /** Test-only: drop the memoized probe result. */
39
46
  export function resetPsnrFilterAvailabilityCache() {
40
- cachedFilterList = null;
47
+ cachedLists.clear();
41
48
  }
42
- async function listFilters() {
49
+ async function listFfmpeg(flag) {
43
50
  // Match `psnr.ts`: promisify lazily so a partial `node:child_process` mock
44
51
  // (test that omits `execFile`) doesn't crash at module load — it fails at
45
52
  // call time instead, and the try/catch below reports no filters at all.
46
53
  const execFileP = promisify(execFile);
47
54
  try {
48
- const { stdout } = await execFileP(getFfmpegBinary(), ["-hide_banner", "-filters"], {
55
+ const { stdout } = await execFileP(getFfmpegBinary(), ["-hide_banner", flag], {
49
56
  maxBuffer: 4 * 1024 * 1024,
50
57
  timeout: 5_000,
51
58
  windowsHide: true,
@@ -7,9 +7,8 @@
7
7
  import { type ManagedProcessTerminationReason } from "./managedChildProcess.js";
8
8
  export interface RunFfmpegOptions {
9
9
  signal?: AbortSignal;
10
+ /** Stop after this long without a higher frame count or output time (counted from spawn until one). */
10
11
  timeout?: number;
11
- /** Replaces `timeout`: stop only once this long passes without a new output frame. */
12
- stallTimeout?: number;
13
12
  onStderr?: (line: string) => void;
14
13
  }
15
14
  export interface RunFfmpegResult {
@@ -49,6 +48,6 @@ export declare function ffmpegStatsReader(onStats: (stats: FfmpegStats) => void)
49
48
  * exactly which option to fix.
50
49
  */
51
50
  export declare function formatFfmpegError(exitCode: number | null, stderr: string, tailLines?: number): string;
52
- /** formatFfmpegError, led by the timeout when this process was killed at its deadline. */
51
+ /** formatFfmpegError, led by the timeout when this process was killed for making no progress. */
53
52
  export declare function describeFfmpegFailure(result: Pick<RunFfmpegResult, "exitCode" | "signal" | "stderr" | "terminationReason">, timeoutMs: number): string;
54
53
  export declare function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult>;