@hyperframes/engine 0.8.44 → 0.8.46
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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts +33 -0
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +110 -1
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/frameCapture.d.ts +39 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +288 -99
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/motionBlur.d.ts +133 -0
- package/dist/services/motionBlur.d.ts.map +1 -0
- package/dist/services/motionBlur.js +275 -0
- package/dist/services/motionBlur.js.map +1 -0
- package/dist/types.d.ts +19 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/alphaBlit.d.ts +33 -0
- package/dist/utils/alphaBlit.d.ts.map +1 -1
- package/dist/utils/alphaBlit.js +52 -4
- package/dist/utils/alphaBlit.js.map +1 -1
- package/dist/utils/crc32.d.ts +2 -0
- package/dist/utils/crc32.d.ts.map +1 -0
- package/dist/utils/crc32.js +35 -0
- package/dist/utils/crc32.js.map +1 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +1 -31
- package/dist/utils/ffprobe.js.map +1 -1
- package/package.json +3 -3
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
|
-
import { quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
|
|
12
|
+
import { quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
|
|
13
13
|
import { DrawElementCaptureError } from "./drawElementCaptureError.js";
|
|
14
|
+
import { encodePng } from "../utils/alphaBlit.js";
|
|
15
|
+
import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
|
|
14
16
|
// ── Extracted modules ───────────────────────────────────────────────────────
|
|
15
17
|
import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, } from "./browserManager.js";
|
|
16
18
|
import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
|
|
@@ -1653,7 +1655,7 @@ export async function initializeSession(session) {
|
|
|
1653
1655
|
await initDrawElementOrTransparentBackground(session, page, logInitPhase);
|
|
1654
1656
|
await armStaticDedup(session, session.page, logInitPhase);
|
|
1655
1657
|
await ensureRenderFrameSiblings(session.page);
|
|
1656
|
-
session
|
|
1658
|
+
finalizeSessionInit(session);
|
|
1657
1659
|
return;
|
|
1658
1660
|
}
|
|
1659
1661
|
// In BeginFrame mode, Chrome's event loop is paused until we issue frames.
|
|
@@ -1821,7 +1823,7 @@ export async function initializeSession(session) {
|
|
|
1821
1823
|
await ensureRenderFrameSiblings(page);
|
|
1822
1824
|
const commitCdp = await getCdpSession(page);
|
|
1823
1825
|
await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams);
|
|
1824
|
-
session
|
|
1826
|
+
finalizeSessionInit(session);
|
|
1825
1827
|
}
|
|
1826
1828
|
async function captureFrameErrorDiagnostics(session, frameIndex, time, error) {
|
|
1827
1829
|
try {
|
|
@@ -1856,23 +1858,30 @@ export async function waitForPendingSeekCompletion(page) {
|
|
|
1856
1858
|
await waitForCompletion?.();
|
|
1857
1859
|
});
|
|
1858
1860
|
}
|
|
1859
|
-
|
|
1861
|
+
/**
|
|
1862
|
+
* Seek the page timeline and report whether a page-side composite is pending.
|
|
1863
|
+
*
|
|
1864
|
+
* The page's `seek()` owns all framework-specific stepping (GSAP, CSS animations, WAAPI);
|
|
1865
|
+
* the options object reaches it untouched through the producer's `__hf.seek` bridge.
|
|
1866
|
+
* Seek and pending-flag read share one round trip.
|
|
1867
|
+
*/
|
|
1868
|
+
async function seekPageTimeline(page, time, seekOptions) {
|
|
1869
|
+
return page.evaluate((t, opts) => {
|
|
1870
|
+
if (window.__hf && typeof window.__hf.seek === "function") {
|
|
1871
|
+
window.__hf.seek(t, opts);
|
|
1872
|
+
}
|
|
1873
|
+
return !!window
|
|
1874
|
+
.__hf_page_composite_pending;
|
|
1875
|
+
}, time, seekOptions);
|
|
1876
|
+
}
|
|
1877
|
+
async function prepareFrameForCapture(session, frameIndex, time, seekOptions) {
|
|
1860
1878
|
const { page, options } = session;
|
|
1861
1879
|
if (!session.isInitialized) {
|
|
1862
1880
|
throw new Error("[FrameCapture] Session not initialized");
|
|
1863
1881
|
}
|
|
1864
|
-
const quantizedTime =
|
|
1882
|
+
const quantizedTime = quantizeSeekTime(time, fpsToNumber(options.fps), seekOptions?.subFrameDivisions);
|
|
1865
1883
|
const seekStart = Date.now();
|
|
1866
|
-
|
|
1867
|
-
// all framework-specific logic (GSAP stepping, CSS animation sync, etc.)
|
|
1868
|
-
// Seek + check page-side composite pending flag in one round-trip.
|
|
1869
|
-
const hasPendingComposite = await page.evaluate((t) => {
|
|
1870
|
-
if (window.__hf && typeof window.__hf.seek === "function") {
|
|
1871
|
-
window.__hf.seek(t);
|
|
1872
|
-
}
|
|
1873
|
-
return !!window
|
|
1874
|
-
.__hf_page_composite_pending;
|
|
1875
|
-
}, quantizedTime);
|
|
1884
|
+
const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
|
|
1876
1885
|
await decodeDynamicCssBackgroundImages(page);
|
|
1877
1886
|
const seekMs = Date.now() - seekStart;
|
|
1878
1887
|
// Before-capture hook (e.g. video frame injection) — runs before
|
|
@@ -1976,7 +1985,19 @@ export function isStaticDedupFrameAnalysisSafe(totalFrames) {
|
|
|
1976
1985
|
* a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
|
|
1977
1986
|
*/
|
|
1978
1987
|
export async function computeStaticFrameSet(page, fps) {
|
|
1979
|
-
const result = await page.evaluate(() => {
|
|
1988
|
+
const result = await page.evaluate((spatialProps) => {
|
|
1989
|
+
// Passed in from SPATIAL_TWEEN_PROPERTIES rather than declared here, so the list
|
|
1990
|
+
// stays the one this module's own tests exercise (the matching code itself must
|
|
1991
|
+
// stay inline — this closure is serialized and runs in the page realm).
|
|
1992
|
+
const SPATIAL_PROPS = new Set(spatialProps);
|
|
1993
|
+
function isSpatial(vars) {
|
|
1994
|
+
if (!vars)
|
|
1995
|
+
return false;
|
|
1996
|
+
for (const key of Object.keys(vars))
|
|
1997
|
+
if (SPATIAL_PROPS.has(key))
|
|
1998
|
+
return true;
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
1980
2001
|
const intervals = [];
|
|
1981
2002
|
let tweenCount = 0;
|
|
1982
2003
|
// totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
|
|
@@ -2002,8 +2023,9 @@ export async function computeStaticFrameSet(page, fps) {
|
|
|
2002
2023
|
// Mark its entire span as animated so those frames are never deduped.
|
|
2003
2024
|
if (typeof tl.vars?.onUpdate === "function") {
|
|
2004
2025
|
const total = typeof tl.totalDuration === "function" ? tl.totalDuration() : 0;
|
|
2026
|
+
// onUpdate can move anything (e.g. x/y from Math.random) — conservatively spatial.
|
|
2005
2027
|
if (total > 0)
|
|
2006
|
-
intervals.push({ start: offset, end: offset + total });
|
|
2028
|
+
intervals.push({ start: offset, end: offset + total, spatial: true });
|
|
2007
2029
|
}
|
|
2008
2030
|
for (const child of tl.getChildren(false, true, true)) {
|
|
2009
2031
|
const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
|
|
@@ -2011,7 +2033,10 @@ export async function computeStaticFrameSet(page, fps) {
|
|
|
2011
2033
|
const total = typeof child.totalDuration === "function" ? child.totalDuration() : single;
|
|
2012
2034
|
if (typeof child.getChildren === "function") {
|
|
2013
2035
|
if (total > single + 1e-6) {
|
|
2014
|
-
|
|
2036
|
+
// A repeating nested timeline's own children are walked for their real
|
|
2037
|
+
// vars below; this span is a conservative (spatial) placeholder for the
|
|
2038
|
+
// repeat/yoyo overrun, which a per-child walk doesn't otherwise cover.
|
|
2039
|
+
intervals.push({ start, end: start + total, spatial: true });
|
|
2015
2040
|
// Still descend for hasTimelineCall even though the repeating
|
|
2016
2041
|
// span is already opaque (its frames are excluded from dedup
|
|
2017
2042
|
// regardless): a call() inside it is a review-flagged detection
|
|
@@ -2026,7 +2051,7 @@ export async function computeStaticFrameSet(page, fps) {
|
|
|
2026
2051
|
}
|
|
2027
2052
|
else {
|
|
2028
2053
|
tweenCount++;
|
|
2029
|
-
intervals.push({ start, end: start + total });
|
|
2054
|
+
intervals.push({ start, end: start + total, spatial: isSpatial(child.vars) });
|
|
2030
2055
|
if (total <= 1e-6 &&
|
|
2031
2056
|
(typeof child.vars?.onComplete === "function" ||
|
|
2032
2057
|
typeof child.vars?.onReverseComplete === "function")) {
|
|
@@ -2074,13 +2099,14 @@ export async function computeStaticFrameSet(page, fps) {
|
|
|
2074
2099
|
hasUnresolvableClipStart,
|
|
2075
2100
|
hasTimelineCall,
|
|
2076
2101
|
};
|
|
2077
|
-
});
|
|
2102
|
+
}, SPATIAL_TWEEN_PROPERTIES);
|
|
2078
2103
|
const { intervals, tweenCount, duration, hasVideo, hasCanvas, hasNonGsapAnim, hasUnresolvableClipStart, hasTimelineCall, } = result;
|
|
2079
2104
|
const totalFrames = Math.max(1, Math.ceil(duration * fps));
|
|
2080
2105
|
if (!isStaticDedupFrameAnalysisSafe(totalFrames)) {
|
|
2081
2106
|
return {
|
|
2082
2107
|
totalFrames,
|
|
2083
2108
|
staticFrameSet: new Set(),
|
|
2109
|
+
nonSpatialOnlyFrameSet: new Set(),
|
|
2084
2110
|
hasVideo,
|
|
2085
2111
|
hasCanvas,
|
|
2086
2112
|
hasNonGsapAnim,
|
|
@@ -2090,11 +2116,15 @@ export async function computeStaticFrameSet(page, fps) {
|
|
|
2090
2116
|
};
|
|
2091
2117
|
}
|
|
2092
2118
|
const animated = new Set();
|
|
2093
|
-
|
|
2119
|
+
const spatialFrameSet = new Set();
|
|
2120
|
+
for (const { start, end, spatial } of intervals) {
|
|
2094
2121
|
const lo = Math.max(0, Math.floor(start * fps));
|
|
2095
2122
|
const hi = Math.min(totalFrames - 1, Math.ceil(end * fps));
|
|
2096
|
-
for (let f = lo; f <= hi; f++)
|
|
2123
|
+
for (let f = lo; f <= hi; f++) {
|
|
2097
2124
|
animated.add(f);
|
|
2125
|
+
if (spatial)
|
|
2126
|
+
spatialFrameSet.add(f);
|
|
2127
|
+
}
|
|
2098
2128
|
}
|
|
2099
2129
|
for (const f of await computeClipBoundaryFrames(page, fps))
|
|
2100
2130
|
animated.add(f);
|
|
@@ -2126,9 +2156,16 @@ export async function computeStaticFrameSet(page, fps) {
|
|
|
2126
2156
|
staticFrameSet.add(f);
|
|
2127
2157
|
}
|
|
2128
2158
|
}
|
|
2159
|
+
const nonSpatialOnlyFrameSet = new Set();
|
|
2160
|
+
if (eligible) {
|
|
2161
|
+
for (const f of animated)
|
|
2162
|
+
if (!spatialFrameSet.has(f))
|
|
2163
|
+
nonSpatialOnlyFrameSet.add(f);
|
|
2164
|
+
}
|
|
2129
2165
|
return {
|
|
2130
2166
|
totalFrames,
|
|
2131
2167
|
staticFrameSet,
|
|
2168
|
+
nonSpatialOnlyFrameSet,
|
|
2132
2169
|
hasVideo,
|
|
2133
2170
|
hasCanvas,
|
|
2134
2171
|
hasNonGsapAnim,
|
|
@@ -2396,6 +2433,21 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
|
|
|
2396
2433
|
* via HF_STATIC_DEDUP_SAMPLES (default 24).
|
|
2397
2434
|
*/
|
|
2398
2435
|
async function armStaticDedup(session, page, logInitPhase) {
|
|
2436
|
+
// Adaptive motion-blur sample-count classification shares the GSAP-timeline walk
|
|
2437
|
+
// below but is gated independently of dedup (capture mode / before-capture hooks are
|
|
2438
|
+
// about buffer-reuse safety, irrelevant to which properties a tween touches), so it
|
|
2439
|
+
// is computed here, once, ahead of dedup's own idempotency check. Cached in
|
|
2440
|
+
// `sharedStaticFrameStats` so dedup's own call further down does not repeat the walk.
|
|
2441
|
+
// Reads `session.options.motionBlur` (the raw caller options), not `session.motionBlur`
|
|
2442
|
+
// (the resolved plan) — every armStaticDedup call site runs before finalizeSessionInit
|
|
2443
|
+
// resolves the plan, so the resolved field is never set yet at this point.
|
|
2444
|
+
let sharedStaticFrameStats;
|
|
2445
|
+
if (session.options.motionBlur &&
|
|
2446
|
+
session.options.motionBlur.samplesPerFrame === undefined &&
|
|
2447
|
+
!session.motionBlurNonSpatialFrames) {
|
|
2448
|
+
sharedStaticFrameStats = await computeStaticFrameSet(page, fpsToNumber(session.options.fps));
|
|
2449
|
+
session.motionBlurNonSpatialFrames = sharedStaticFrameStats.nonSpatialOnlyFrameSet;
|
|
2450
|
+
}
|
|
2399
2451
|
// Idempotent: the drawElement init path arms dedup BEFORE canvas injection
|
|
2400
2452
|
// (verification screenshots need the un-injected DOM), and initializeSession
|
|
2401
2453
|
// calls this again unconditionally afterwards. Once staticFrames is
|
|
@@ -2442,7 +2494,7 @@ async function armStaticDedup(session, page, logInitPhase) {
|
|
|
2442
2494
|
return;
|
|
2443
2495
|
}
|
|
2444
2496
|
const fps = fpsToNumber(session.options.fps);
|
|
2445
|
-
const stats = await computeStaticFrameSet(page, fps);
|
|
2497
|
+
const stats = sharedStaticFrameStats ?? (await computeStaticFrameSet(page, fps));
|
|
2446
2498
|
if (!stats.eligible || stats.staticFrameSet.size === 0) {
|
|
2447
2499
|
session.staticDedupSkipReason = "ineligible";
|
|
2448
2500
|
logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
|
|
@@ -2676,8 +2728,192 @@ export async function withFrameDeadline(work, label, ms, onTimeout) {
|
|
|
2676
2728
|
});
|
|
2677
2729
|
}
|
|
2678
2730
|
}
|
|
2679
|
-
|
|
2731
|
+
/**
|
|
2732
|
+
* Resolve the session's motion-blur plan, rejecting combinations the accumulation pass
|
|
2733
|
+
* cannot render correctly instead of silently producing an unblurred frame.
|
|
2734
|
+
*
|
|
2735
|
+
* Called once initialization has settled the capture mode. `format: "png"` is required
|
|
2736
|
+
* because samples are averaged pixel by pixel: JPEG samples would be averaged after
|
|
2737
|
+
* lossy quantization and the blended frame is re-encoded as PNG. `<video>` content is
|
|
2738
|
+
* out of scope because it is supplied by the before-capture frame-injection hook rather
|
|
2739
|
+
* than by the timeline seek, so it cannot follow a sub-frame time.
|
|
2740
|
+
*/
|
|
2741
|
+
export function resolveSessionMotionBlur(session) {
|
|
2742
|
+
const plan = resolveMotionBlurPlan(session.options.motionBlur);
|
|
2743
|
+
if (!plan)
|
|
2744
|
+
return undefined;
|
|
2745
|
+
if (session.captureMode !== "screenshot") {
|
|
2746
|
+
throw new Error(`[MotionBlur] sub-frame motion blur requires screenshot capture mode, got "${session.captureMode}"`);
|
|
2747
|
+
}
|
|
2748
|
+
if (session.options.format !== "png") {
|
|
2749
|
+
throw new Error(`[MotionBlur] sub-frame motion blur requires format "png", got "${session.options.format ?? "jpeg"}"`);
|
|
2750
|
+
}
|
|
2751
|
+
if (session.onBeforeCapture) {
|
|
2752
|
+
throw new Error("[MotionBlur] sub-frame motion blur cannot run with injected video frames: video content is extracted per output frame and does not follow a sub-frame seek");
|
|
2753
|
+
}
|
|
2754
|
+
return plan;
|
|
2755
|
+
}
|
|
2756
|
+
/** Seek to `time` and capture one surface with the session's capture mode. */
|
|
2757
|
+
async function captureFrameSurface(session, frameIndex, time, seekOptions) {
|
|
2680
2758
|
const { page, options } = session;
|
|
2759
|
+
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time, seekOptions);
|
|
2760
|
+
const screenshotStart = Date.now();
|
|
2761
|
+
let screenshotBuffer;
|
|
2762
|
+
if (session.captureMode === "beginframe") {
|
|
2763
|
+
const frameTimeTicks = session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs;
|
|
2764
|
+
const result = await beginFrameCapture(page, options, frameTimeTicks, session.beginFrameIntervalMs);
|
|
2765
|
+
if (result.hasDamage)
|
|
2766
|
+
session.beginFrameHasDamageCount++;
|
|
2767
|
+
else
|
|
2768
|
+
session.beginFrameNoDamageCount++;
|
|
2769
|
+
screenshotBuffer = result.buffer;
|
|
2770
|
+
}
|
|
2771
|
+
else if (session.captureMode === "drawelement" &&
|
|
2772
|
+
session.clipBoundaryFrames?.has(frameIndex) &&
|
|
2773
|
+
process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
|
|
2774
|
+
throw new DrawElementCaptureError(frameIndex, "boundary screenshot requested on an injected canvas page");
|
|
2775
|
+
}
|
|
2776
|
+
else if (session.captureMode === "drawelement") {
|
|
2777
|
+
// Advance compositor state via BeginFrame when available (Linux headless-shell);
|
|
2778
|
+
// on macOS the compositor advances naturally without BeginFrame.
|
|
2779
|
+
if (session.beginFrameTimeTicks > 0) {
|
|
2780
|
+
const client = await getCdpSession(page);
|
|
2781
|
+
await client.send("HeadlessExperimental.beginFrame", {
|
|
2782
|
+
frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
|
|
2783
|
+
interval: session.beginFrameIntervalMs,
|
|
2784
|
+
noDisplayUpdates: false,
|
|
2785
|
+
// no screenshot param — we capture via canvas
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
try {
|
|
2789
|
+
screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
|
|
2790
|
+
// Paint-event sync only without BeginFrame (macOS / screenshot-launched):
|
|
2791
|
+
// under BeginFrame control the per-frame beginFrame above already painted
|
|
2792
|
+
// a fresh snapshot, and no further paint would arrive during a wait.
|
|
2793
|
+
session.beginFrameTimeTicks === 0);
|
|
2794
|
+
// A tiny JPEG may be a dropped paint record. Never screenshot this page:
|
|
2795
|
+
// its injected canvas can still hold the preceding frame's bitmap.
|
|
2796
|
+
// Restart on a fresh screenshot page even if this was a simple valid frame.
|
|
2797
|
+
if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
|
|
2798
|
+
const sizes = (session.deFrameSizes ??= []);
|
|
2799
|
+
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
|
|
2800
|
+
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
|
|
2801
|
+
const floor = Math.max(20000, median * 0.12);
|
|
2802
|
+
if (screenshotBuffer.length < floor) {
|
|
2803
|
+
throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
|
|
2804
|
+
}
|
|
2805
|
+
else {
|
|
2806
|
+
if (sizes.length >= 60)
|
|
2807
|
+
sizes.shift();
|
|
2808
|
+
sizes.push(screenshotBuffer.length);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
catch (err) {
|
|
2813
|
+
// Missing paint records/canvas state require a new screenshot page.
|
|
2814
|
+
if (isRecoverableDrawElementError(err)) {
|
|
2815
|
+
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
|
|
2816
|
+
const reason = isCanvasNotInitializedError(err)
|
|
2817
|
+
? "drawElement canvas not initialized"
|
|
2818
|
+
: "No cached paint record";
|
|
2819
|
+
throw new DrawElementCaptureError(frameIndex, reason, err);
|
|
2820
|
+
}
|
|
2821
|
+
else {
|
|
2822
|
+
throw err;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
else {
|
|
2827
|
+
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
2828
|
+
}
|
|
2829
|
+
const screenshotMs = Date.now() - screenshotStart;
|
|
2830
|
+
return { buffer: screenshotBuffer, quantizedTime, seekMs, beforeCaptureMs, screenshotMs };
|
|
2831
|
+
}
|
|
2832
|
+
/**
|
|
2833
|
+
* Mark the session ready to capture.
|
|
2834
|
+
*
|
|
2835
|
+
* The single owner of what finishing initialization means, because `initializeSession`
|
|
2836
|
+
* has two exits: screenshot mode returns early, and every other mode falls through the
|
|
2837
|
+
* end. Resolving motion blur at only one of them marks the session ready with no plan,
|
|
2838
|
+
* which silently renders unblurred rather than failing. Both fields are set here so a
|
|
2839
|
+
* third exit cannot forget one.
|
|
2840
|
+
*/
|
|
2841
|
+
function finalizeSessionInit(session) {
|
|
2842
|
+
session.motionBlur = resolveSessionMotionBlur(session);
|
|
2843
|
+
session.isInitialized = true;
|
|
2844
|
+
}
|
|
2845
|
+
/** Choose how many samples this frame gets when the plan is adaptive: the floor with
|
|
2846
|
+
* no probe for a frame confirmed non-spatial, otherwise two edge probes (see
|
|
2847
|
+
* motionBlurProbeTimes) mapped through adaptiveSampleCount. Probe cost is returned
|
|
2848
|
+
* alongside the count so the caller folds it into the frame's timing totals. */
|
|
2849
|
+
async function resolveAdaptiveSampleCount(session, frameIndex, absFrameIndex, plan, fps, sampleSeek) {
|
|
2850
|
+
if (session.motionBlurNonSpatialFrames?.has(absFrameIndex)) {
|
|
2851
|
+
return {
|
|
2852
|
+
samplesPerFrame: DEFAULT_SAMPLES_PER_FRAME,
|
|
2853
|
+
seekMs: 0,
|
|
2854
|
+
beforeCaptureMs: 0,
|
|
2855
|
+
screenshotMs: 0,
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
const { windowStart, windowEnd } = motionBlurProbeTimes(plan, absFrameIndex, fps);
|
|
2859
|
+
const probeA = await captureFrameSurface(session, frameIndex, windowStart, sampleSeek);
|
|
2860
|
+
const probeB = await captureFrameSurface(session, frameIndex, windowEnd, sampleSeek);
|
|
2861
|
+
return {
|
|
2862
|
+
samplesPerFrame: adaptiveSampleCount(probeDiffMagnitude(probeA.buffer, probeB.buffer)),
|
|
2863
|
+
seekMs: probeA.seekMs + probeB.seekMs,
|
|
2864
|
+
beforeCaptureMs: probeA.beforeCaptureMs + probeB.beforeCaptureMs,
|
|
2865
|
+
screenshotMs: probeA.screenshotMs + probeB.screenshotMs,
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Capture one output frame as the average of some number of sub-frame captures — fixed
|
|
2870
|
+
* by the caller, or chosen per frame from measured motion (see
|
|
2871
|
+
* `resolveAdaptiveSampleCount`).
|
|
2872
|
+
*
|
|
2873
|
+
* Callback invariant: exactly one eventful seek per output frame, at the frame time,
|
|
2874
|
+
* arriving from the previous frame's time. Every sample seek (including a probe)
|
|
2875
|
+
* suppresses events and the playhead is restored to the frame time afterwards, so a
|
|
2876
|
+
* composition's own onUpdate/onComplete fire on the same interval boundaries as a
|
|
2877
|
+
* render with blur off.
|
|
2878
|
+
*/
|
|
2879
|
+
async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan) {
|
|
2880
|
+
const fps = fpsToNumber(session.options.fps);
|
|
2881
|
+
const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
|
|
2882
|
+
const eventfulSeekStart = Date.now();
|
|
2883
|
+
await seekPageTimeline(session.page, frameTime, undefined);
|
|
2884
|
+
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
|
|
2885
|
+
const sampleSeek = {
|
|
2886
|
+
suppressEvents: true,
|
|
2887
|
+
subFrameDivisions: plan.subFrameDivisions,
|
|
2888
|
+
};
|
|
2889
|
+
let samplesPerFrame = plan.fixedSamplesPerFrame;
|
|
2890
|
+
if (samplesPerFrame === null) {
|
|
2891
|
+
const chosen = await resolveAdaptiveSampleCount(session, frameIndex, absFrameIndex, plan, fps, sampleSeek);
|
|
2892
|
+
samplesPerFrame = chosen.samplesPerFrame;
|
|
2893
|
+
totals.seekMs += chosen.seekMs;
|
|
2894
|
+
totals.beforeCaptureMs += chosen.beforeCaptureMs;
|
|
2895
|
+
totals.screenshotMs += chosen.screenshotMs;
|
|
2896
|
+
}
|
|
2897
|
+
const accumulator = new MotionBlurAccumulator(plan.blend);
|
|
2898
|
+
for (const sampleTime of motionBlurSampleTimes(plan, absFrameIndex, fps, samplesPerFrame)) {
|
|
2899
|
+
const sample = await captureFrameSurface(session, frameIndex, sampleTime, sampleSeek);
|
|
2900
|
+
totals.seekMs += sample.seekMs;
|
|
2901
|
+
totals.beforeCaptureMs += sample.beforeCaptureMs;
|
|
2902
|
+
totals.screenshotMs += sample.screenshotMs;
|
|
2903
|
+
accumulator.add(sample.buffer);
|
|
2904
|
+
}
|
|
2905
|
+
const restoreSeekStart = Date.now();
|
|
2906
|
+
await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
|
|
2907
|
+
totals.seekMs += Date.now() - restoreSeekStart;
|
|
2908
|
+
const blended = accumulator.finish();
|
|
2909
|
+
return {
|
|
2910
|
+
buffer: encodePng(blended.width, blended.height, blended.data),
|
|
2911
|
+
quantizedTime: frameTime,
|
|
2912
|
+
...totals,
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
async function captureFrameCore(session, frameIndex, time) {
|
|
2916
|
+
const { options } = session;
|
|
2681
2917
|
const startTime = Date.now();
|
|
2682
2918
|
// Static-frame dedup: this frame is byte-identical to its predecessor (predicted +
|
|
2683
2919
|
// anchor-verified at init) → reuse the prior buffer, skip the seek + screenshot.
|
|
@@ -2696,7 +2932,9 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
2696
2932
|
const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
|
|
2697
2933
|
if (session.staticFrames?.has(absFrameIndex) &&
|
|
2698
2934
|
session.lastFrameBuffer &&
|
|
2699
|
-
session.lastFrameAbsoluteIndex === absFrameIndex - 1
|
|
2935
|
+
session.lastFrameAbsoluteIndex === absFrameIndex - 1 &&
|
|
2936
|
+
(!session.motionBlur ||
|
|
2937
|
+
motionBlurWindowIsStatic(session.motionBlur, absFrameIndex, session.staticFrames))) {
|
|
2700
2938
|
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
|
|
2701
2939
|
session.lastFrameAbsoluteIndex = absFrameIndex;
|
|
2702
2940
|
return {
|
|
@@ -2706,77 +2944,12 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
2706
2944
|
};
|
|
2707
2945
|
}
|
|
2708
2946
|
try {
|
|
2709
|
-
const
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
if (result.hasDamage)
|
|
2716
|
-
session.beginFrameHasDamageCount++;
|
|
2717
|
-
else
|
|
2718
|
-
session.beginFrameNoDamageCount++;
|
|
2719
|
-
screenshotBuffer = result.buffer;
|
|
2720
|
-
}
|
|
2721
|
-
else if (session.captureMode === "drawelement" &&
|
|
2722
|
-
session.clipBoundaryFrames?.has(frameIndex) &&
|
|
2723
|
-
process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
|
|
2724
|
-
throw new DrawElementCaptureError(frameIndex, "boundary screenshot requested on an injected canvas page");
|
|
2725
|
-
}
|
|
2726
|
-
else if (session.captureMode === "drawelement") {
|
|
2727
|
-
// Advance compositor state via BeginFrame when available (Linux headless-shell);
|
|
2728
|
-
// on macOS the compositor advances naturally without BeginFrame.
|
|
2729
|
-
if (session.beginFrameTimeTicks > 0) {
|
|
2730
|
-
const client = await getCdpSession(page);
|
|
2731
|
-
await client.send("HeadlessExperimental.beginFrame", {
|
|
2732
|
-
frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
|
|
2733
|
-
interval: session.beginFrameIntervalMs,
|
|
2734
|
-
noDisplayUpdates: false,
|
|
2735
|
-
// no screenshot param — we capture via canvas
|
|
2736
|
-
});
|
|
2737
|
-
}
|
|
2738
|
-
try {
|
|
2739
|
-
screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
|
|
2740
|
-
// Paint-event sync only without BeginFrame (macOS / screenshot-launched):
|
|
2741
|
-
// under BeginFrame control the per-frame beginFrame above already painted
|
|
2742
|
-
// a fresh snapshot, and no further paint would arrive during a wait.
|
|
2743
|
-
session.beginFrameTimeTicks === 0);
|
|
2744
|
-
// A tiny JPEG may be a dropped paint record. Never screenshot this page:
|
|
2745
|
-
// its injected canvas can still hold the preceding frame's bitmap.
|
|
2746
|
-
// Restart on a fresh screenshot page even if this was a simple valid frame.
|
|
2747
|
-
if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
|
|
2748
|
-
const sizes = (session.deFrameSizes ??= []);
|
|
2749
|
-
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
|
|
2750
|
-
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
|
|
2751
|
-
const floor = Math.max(20000, median * 0.12);
|
|
2752
|
-
if (screenshotBuffer.length < floor) {
|
|
2753
|
-
throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
|
|
2754
|
-
}
|
|
2755
|
-
else {
|
|
2756
|
-
if (sizes.length >= 60)
|
|
2757
|
-
sizes.shift();
|
|
2758
|
-
sizes.push(screenshotBuffer.length);
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
catch (err) {
|
|
2763
|
-
// Missing paint records/canvas state require a new screenshot page.
|
|
2764
|
-
if (isRecoverableDrawElementError(err)) {
|
|
2765
|
-
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
|
|
2766
|
-
const reason = isCanvasNotInitializedError(err)
|
|
2767
|
-
? "drawElement canvas not initialized"
|
|
2768
|
-
: "No cached paint record";
|
|
2769
|
-
throw new DrawElementCaptureError(frameIndex, reason, err);
|
|
2770
|
-
}
|
|
2771
|
-
else {
|
|
2772
|
-
throw err;
|
|
2773
|
-
}
|
|
2774
|
-
}
|
|
2775
|
-
}
|
|
2776
|
-
else {
|
|
2777
|
-
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
2778
|
-
}
|
|
2779
|
-
const screenshotMs = Date.now() - screenshotStart;
|
|
2947
|
+
const plan = session.motionBlur;
|
|
2948
|
+
// One capture per frame, or `plan.samplesPerFrame` captures averaged into one. Both
|
|
2949
|
+
// report the same timings, so perf accounting and the dedup anchor stay shared.
|
|
2950
|
+
const { buffer, quantizedTime, seekMs, beforeCaptureMs, screenshotMs } = plan
|
|
2951
|
+
? await captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
|
|
2952
|
+
: await captureFrameSurface(session, frameIndex, time);
|
|
2780
2953
|
const captureTimeMs = Date.now() - startTime;
|
|
2781
2954
|
session.capturePerf.frames += 1;
|
|
2782
2955
|
session.capturePerf.seekMs += seekMs;
|
|
@@ -2786,10 +2959,10 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
2786
2959
|
session.capturePerf.frameMs.push(captureTimeMs);
|
|
2787
2960
|
// Retain this freshly-captured buffer so the following static frames can reuse it.
|
|
2788
2961
|
if (session.staticFrames) {
|
|
2789
|
-
session.lastFrameBuffer =
|
|
2962
|
+
session.lastFrameBuffer = buffer;
|
|
2790
2963
|
session.lastFrameAbsoluteIndex = absFrameIndex;
|
|
2791
2964
|
}
|
|
2792
|
-
return { buffer
|
|
2965
|
+
return { buffer, quantizedTime, captureTimeMs };
|
|
2793
2966
|
}
|
|
2794
2967
|
catch (captureError) {
|
|
2795
2968
|
if (session.isInitialized) {
|
|
@@ -2803,6 +2976,15 @@ export async function captureFrame(session, frameIndex, time) {
|
|
|
2803
2976
|
const framePath = writeCapturedFrame(session, frameIndex, buffer);
|
|
2804
2977
|
return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
|
|
2805
2978
|
}
|
|
2979
|
+
/**
|
|
2980
|
+
* File extension for a captured frame, keyed on the format the frames were actually
|
|
2981
|
+
* captured in. The encoder's input pattern and the writer must agree, so both read this
|
|
2982
|
+
* rather than re-deriving the answer from whether the OUTPUT needs alpha, which is a
|
|
2983
|
+
* different question and stops being equivalent as soon as anything else forces PNG.
|
|
2984
|
+
*/
|
|
2985
|
+
export function frameFileExtension(format) {
|
|
2986
|
+
return format === "png" ? "png" : "jpg";
|
|
2987
|
+
}
|
|
2806
2988
|
/**
|
|
2807
2989
|
* Write an already-captured frame buffer to the session's output dir using the
|
|
2808
2990
|
* canonical `frame_NNNNNN.{jpg,png}` naming. `fileIndex` is the ENCODER-facing
|
|
@@ -2812,7 +2994,7 @@ export async function captureFrame(session, frameIndex, time) {
|
|
|
2812
2994
|
* `captureFrameToBufferPipelined` without duplicating the naming convention.
|
|
2813
2995
|
*/
|
|
2814
2996
|
export function writeCapturedFrame(session, fileIndex, buffer) {
|
|
2815
|
-
const ext = session.options.format
|
|
2997
|
+
const ext = frameFileExtension(session.options.format);
|
|
2816
2998
|
const framePath = join(session.outputDir, `frame_${String(fileIndex).padStart(6, "0")}.${ext}`);
|
|
2817
2999
|
writeFileSync(framePath, buffer);
|
|
2818
3000
|
return framePath;
|
|
@@ -2855,6 +3037,13 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
|
|
|
2855
3037
|
* - macOS hardware GPU path (syncToPaintEvent=true, beginFrameTimeTicks=0).
|
|
2856
3038
|
* BeginFrame (Linux) uses the standard synchronous path unchanged.
|
|
2857
3039
|
*/
|
|
3040
|
+
/**
|
|
3041
|
+
* Worker-encode path, gated to drawElement capture. It does not route through
|
|
3042
|
+
* `captureFrameCore` and so has no accumulation branch; that is safe only because
|
|
3043
|
+
* `resolveSessionMotionBlur` rejects every capture mode except screenshot, which makes
|
|
3044
|
+
* this function unreachable with motion blur on. Widening the supported capture modes
|
|
3045
|
+
* means handling accumulation here too.
|
|
3046
|
+
*/
|
|
2858
3047
|
export async function captureFrameToBufferPipelined(session, frameIndex, time) {
|
|
2859
3048
|
const { page, options } = session;
|
|
2860
3049
|
const startTime = Date.now();
|