@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.
- package/dist/config.d.ts +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/services/audioMixer.js +3 -9
- package/dist/services/browserManager.d.ts +3 -3
- package/dist/services/browserManager.js +37 -17
- package/dist/services/chunkEncoder.js +6 -12
- package/dist/services/frameCapture.d.ts +11 -1
- package/dist/services/frameCapture.js +186 -62
- package/dist/services/parallelCoordinator.js +12 -10
- package/dist/services/riskFrames.d.ts +30 -0
- package/dist/services/riskFrames.js +101 -0
- package/dist/services/screenshotService.d.ts +7 -0
- package/dist/services/screenshotService.js +4 -0
- package/dist/services/systemMemory.d.ts +2 -0
- package/dist/services/systemMemory.js +38 -1
- package/dist/services/threeDProjection.d.ts +14 -0
- package/dist/services/threeDProjection.js +117 -41
- package/dist/services/videoFrameExtractor.js +9 -5
- package/dist/services/videoFrameInjector.js +2 -1
- package/dist/types.d.ts +6 -2
- package/dist/utils/psnrFilterAvailability.d.ts +1 -0
- package/dist/utils/psnrFilterAvailability.js +17 -10
- package/dist/utils/runFfmpeg.d.ts +2 -3
- package/dist/utils/runFfmpeg.js +13 -16
- package/dist/utils/urlDownloader.js +21 -7
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
893
|
-
|
|
894
|
-
|
|
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
|
|
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 &&
|
|
901
|
-
|
|
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
|
|
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
|
-
|
|
922
|
-
if (/(?:^|[^-])filter\s*:[^;{}]*blur\(/i.test(txt))
|
|
923
|
-
|
|
924
|
-
if (/(?:^|[^-])filter\s*:[^;{}]*drop-shadow\(/i.test(txt)) {
|
|
925
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
984
|
+
return false;
|
|
939
985
|
for (const c of tl.getChildren(false, true, true)) {
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
948
|
-
if (kl.includes("backdrop"))
|
|
949
|
-
|
|
950
|
-
if (kl === "filter" && /blur\(/i.test(String(vars[k] ?? "")))
|
|
951
|
-
|
|
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
|
|
1009
|
+
return false;
|
|
958
1010
|
};
|
|
959
1011
|
const tls = window.__timelines || {};
|
|
960
1012
|
for (const tl of Object.values(tls)) {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
return r;
|
|
1013
|
+
if (walk(tl))
|
|
1014
|
+
return true;
|
|
964
1015
|
}
|
|
965
|
-
return
|
|
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
|
|
1022
|
+
return accel.length > 0 && hit("webgl-context", null);
|
|
972
1023
|
};
|
|
973
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
+
cachedLists.clear();
|
|
41
48
|
}
|
|
42
|
-
async function
|
|
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",
|
|
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
|
|
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>;
|