@hyperframes/engine 0.8.203 → 0.8.205
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/services/browserManager.d.ts +1 -0
- package/dist/services/browserManager.js +39 -33
- package/dist/services/frameCapture.js +3 -5
- package/dist/services/screenshotService.d.ts +3 -1
- package/dist/services/screenshotService.js +20 -11
- package/dist/services/videoFrameInjector.js +8 -3
- package/package.json +3 -3
|
@@ -48,6 +48,7 @@ interface BeginFrameProbeResult {
|
|
|
48
48
|
supported: boolean;
|
|
49
49
|
detail: string;
|
|
50
50
|
durationMs: number;
|
|
51
|
+
timedOut?: boolean;
|
|
51
52
|
}
|
|
52
53
|
declare function closeBrowserAfterFailedProbe(browser: Browser, timeoutMs?: number): Promise<void>;
|
|
53
54
|
declare function probeBeginFrameSupport(browser: Browser, timeoutMs?: number): Promise<BeginFrameProbeResult>;
|
|
@@ -245,22 +245,25 @@ const BEGINFRAME_ONLY_FLAGS = new Set([
|
|
|
245
245
|
function stripBeginFrameFlags(args) {
|
|
246
246
|
return args.filter((a) => !BEGINFRAME_ONLY_FLAGS.has(a));
|
|
247
247
|
}
|
|
248
|
+
class BeginFrameProbeTimeout extends Error {
|
|
249
|
+
}
|
|
248
250
|
const BEGINFRAME_PROBE_TIMEOUT_MS = 2000;
|
|
251
|
+
const BEGINFRAME_PROBE_RETRY_TIMEOUT_MS = 8000;
|
|
249
252
|
const BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS = 250;
|
|
250
253
|
async function awaitBeforeDeadline(operation, deadline, label) {
|
|
251
254
|
const remainingMs = deadline - Date.now();
|
|
252
255
|
if (remainingMs <= 0) {
|
|
253
|
-
throw new
|
|
256
|
+
throw new BeginFrameProbeTimeout(`beginFrame probe timeout before ${label}`);
|
|
254
257
|
}
|
|
255
|
-
return rejectAfter(operation, remainingMs, `beginFrame probe timeout during ${label}`);
|
|
258
|
+
return rejectAfter(operation, remainingMs, new BeginFrameProbeTimeout(`beginFrame probe timeout during ${label}`));
|
|
256
259
|
}
|
|
257
|
-
async function rejectAfter(operation, timeoutMs,
|
|
260
|
+
async function rejectAfter(operation, timeoutMs, error) {
|
|
258
261
|
let timeout;
|
|
259
262
|
try {
|
|
260
263
|
return await Promise.race([
|
|
261
264
|
operation,
|
|
262
265
|
new Promise((_, reject) => {
|
|
263
|
-
timeout = setTimeout(() => reject(new Error(
|
|
266
|
+
timeout = setTimeout(() => reject(typeof error === "string" ? new Error(error) : error), timeoutMs);
|
|
264
267
|
}),
|
|
265
268
|
]);
|
|
266
269
|
}
|
|
@@ -345,7 +348,8 @@ async function probeBeginFrameSupport(browser, timeoutMs = BEGINFRAME_PROBE_TIME
|
|
|
345
348
|
await awaitBeforeDeadline(new Promise((resolveDelay) => setTimeout(resolveDelay, 10)), deadline, `screenshot retry delay ${attempts}`);
|
|
346
349
|
}
|
|
347
350
|
if (!isPng) {
|
|
348
|
-
|
|
351
|
+
const Failure = bytes.length === 0 ? BeginFrameProbeTimeout : Error;
|
|
352
|
+
throw new Failure(`beginFrame screenshot returned ${bytes.length} bytes after ` +
|
|
349
353
|
`${attempts} attempts in ${Date.now() - started}ms with signature ` +
|
|
350
354
|
`${bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"}`);
|
|
351
355
|
}
|
|
@@ -362,6 +366,7 @@ async function probeBeginFrameSupport(browser, timeoutMs = BEGINFRAME_PROBE_TIME
|
|
|
362
366
|
supported: false,
|
|
363
367
|
detail: error instanceof Error ? error.message : String(error),
|
|
364
368
|
durationMs: Date.now() - started,
|
|
369
|
+
timedOut: error instanceof BeginFrameProbeTimeout,
|
|
365
370
|
};
|
|
366
371
|
}
|
|
367
372
|
if (page && !(await settleWithin(page.close(), BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS))) {
|
|
@@ -653,42 +658,43 @@ async function launchBrowser(fingerprint) {
|
|
|
653
658
|
const env = launchEnv(fingerprint);
|
|
654
659
|
let captureMode = fingerprint.requestedCaptureMode;
|
|
655
660
|
let browser;
|
|
661
|
+
const launch = (args) => ppt.launch({
|
|
662
|
+
...sigintLaunchOptions(),
|
|
663
|
+
headless: true,
|
|
664
|
+
args: withHyperframesBrowserMarker(args),
|
|
665
|
+
defaultViewport: null,
|
|
666
|
+
executablePath: fingerprint.executablePath,
|
|
667
|
+
env,
|
|
668
|
+
timeout: fingerprint.browserTimeoutMs,
|
|
669
|
+
protocolTimeout: fingerprint.protocolTimeoutMs,
|
|
670
|
+
});
|
|
671
|
+
const probe = (target, timeoutMs) => probeBeginFrameSupport(target, timeoutMs).catch((error) => ({
|
|
672
|
+
supported: true,
|
|
673
|
+
detail: `probe harness error ignored: ${error instanceof Error ? error.message : String(error)}`,
|
|
674
|
+
durationMs: 0,
|
|
675
|
+
timedOut: false,
|
|
676
|
+
}));
|
|
656
677
|
try {
|
|
657
|
-
browser = await
|
|
658
|
-
...sigintLaunchOptions(),
|
|
659
|
-
headless: true,
|
|
660
|
-
args: withHyperframesBrowserMarker(fingerprint.args),
|
|
661
|
-
defaultViewport: null,
|
|
662
|
-
executablePath: fingerprint.executablePath,
|
|
663
|
-
env,
|
|
664
|
-
timeout: fingerprint.browserTimeoutMs,
|
|
665
|
-
protocolTimeout: fingerprint.protocolTimeoutMs,
|
|
666
|
-
});
|
|
678
|
+
browser = await launch(fingerprint.args);
|
|
667
679
|
const browserVersion = await browser.version().catch(() => "unknown");
|
|
668
680
|
const gpuFlags = fingerprint.args.filter((a) => a.startsWith("--use-gl=") || a.startsWith("--use-angle="));
|
|
669
681
|
console.error(`[BrowserManager] Browser launched (${browserVersion}, ${captureMode}, gl=${gpuFlags.join(" ") || "default"}, headlessShell=${!!fingerprint.executablePath}, platform=${process.platform})`);
|
|
670
682
|
if (captureMode === "beginframe") {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
683
|
+
let result = await probe(browser);
|
|
684
|
+
if (!result.supported && result.timedOut) {
|
|
685
|
+
console.warn(`[BrowserManager] HeadlessExperimental.beginFrame probe timed out after ${result.durationMs}ms: ` +
|
|
686
|
+
`${result.detail}; retrying once with a longer budget.`);
|
|
687
|
+
await closeBrowserAfterFailedProbe(browser);
|
|
688
|
+
browser = await launch(fingerprint.args);
|
|
689
|
+
result = await probe(browser, BEGINFRAME_PROBE_RETRY_TIMEOUT_MS);
|
|
690
|
+
}
|
|
691
|
+
if (!result.supported) {
|
|
677
692
|
await closeBrowserAfterFailedProbe(browser);
|
|
678
693
|
browser = undefined;
|
|
679
|
-
console.warn(`[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${
|
|
680
|
-
`${
|
|
694
|
+
console.warn(`[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${result.durationMs}ms: ` +
|
|
695
|
+
`${result.detail}; falling back to screenshot mode.`);
|
|
681
696
|
captureMode = "screenshot";
|
|
682
|
-
browser = await
|
|
683
|
-
...sigintLaunchOptions(),
|
|
684
|
-
headless: true,
|
|
685
|
-
args: withHyperframesBrowserMarker(stripBeginFrameFlags([...fingerprint.args])),
|
|
686
|
-
defaultViewport: null,
|
|
687
|
-
executablePath: fingerprint.executablePath,
|
|
688
|
-
env,
|
|
689
|
-
timeout: fingerprint.browserTimeoutMs,
|
|
690
|
-
protocolTimeout: fingerprint.protocolTimeoutMs,
|
|
691
|
-
});
|
|
697
|
+
browser = await launch(stripBeginFrameFlags([...fingerprint.args]));
|
|
692
698
|
}
|
|
693
699
|
}
|
|
694
700
|
return { browser, captureMode };
|
|
@@ -834,7 +834,6 @@ async function installMediaPageHooks(page, options) {
|
|
|
834
834
|
await page.evaluateOnNewDocument(holdRenderFrameVideos, options.skipReadinessVideoIds, MEDIA_RENDER_ID_ATTR);
|
|
835
835
|
}
|
|
836
836
|
}
|
|
837
|
-
// fallow-ignore-next-line unit-size
|
|
838
837
|
export async function createCaptureSession(serverUrl, outputDir, options, onBeforeCapture = null, config) {
|
|
839
838
|
if (!existsSync(outputDir))
|
|
840
839
|
mkdirSync(outputDir, { recursive: true });
|
|
@@ -1845,7 +1844,6 @@ export function classifyPageError(details, projectScripts) {
|
|
|
1845
1844
|
const owned = (url) => url !== undefined && (projectScripts.has(url) || url.startsWith(COMPOSITION_SOURCE_URL));
|
|
1846
1845
|
return urls.some(owned) ? `runtime-error:${firstLine}` : null;
|
|
1847
1846
|
}
|
|
1848
|
-
// fallow-ignore-next-line unit-size
|
|
1849
1847
|
export async function initializeSession(session) {
|
|
1850
1848
|
const { page, serverUrl } = session;
|
|
1851
1849
|
// Forward browser console to host. HyperFrames runtime logs get a dedicated
|
|
@@ -3339,8 +3337,8 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
|
|
|
3339
3337
|
await seekPageTimeline(session.page, frameTime, undefined);
|
|
3340
3338
|
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
|
|
3341
3339
|
if (session.onBeforeCapture) {
|
|
3342
|
-
//
|
|
3343
|
-
//
|
|
3340
|
+
// Sub-samples leave a video's style alone: inject at frameTime first so the held frame
|
|
3341
|
+
// carries frameTime's style, not the first sample's.
|
|
3344
3342
|
const injectStart = Date.now();
|
|
3345
3343
|
session.captureStep = "injecting video frames";
|
|
3346
3344
|
await session.onBeforeCapture(session.page, frameTime, frameTime);
|
|
@@ -3765,7 +3763,7 @@ export async function closeCaptureSession(session) {
|
|
|
3765
3763
|
session.chromeMemory.stop();
|
|
3766
3764
|
// One last sample so the summary reflects the session's end state, not a
|
|
3767
3765
|
// point up to `intervalMs` earlier. Best effort; the page may be gone.
|
|
3768
|
-
await session.chromeMemory.sampleOnce();
|
|
3766
|
+
await waitForCloseWithTimeout(session.chromeMemory.sampleOnce());
|
|
3769
3767
|
}
|
|
3770
3768
|
if (session.page && !session.pageReleased) {
|
|
3771
3769
|
const blur = await describePageMotionBlurAtEnd(session.page);
|
|
@@ -194,9 +194,11 @@ export declare function ensureRenderFrameSiblings(page: Page): Promise<void>;
|
|
|
194
194
|
* cache entry for a frame that never reached the page, which would otherwise
|
|
195
195
|
* short-circuit the next inject at the same frameIndex and leave the host's
|
|
196
196
|
* first visible frame blank.
|
|
197
|
+
*
|
|
198
|
+
* `restyleIds` keep their current picture and only re-copy the video's style.
|
|
197
199
|
*/
|
|
198
200
|
export declare function injectVideoFramesBatch(page: Page, updates: Array<{
|
|
199
201
|
videoId: string;
|
|
200
202
|
dataUri: string;
|
|
201
|
-
}
|
|
203
|
+
}>, restyleIds?: readonly string[]): Promise<string[]>;
|
|
202
204
|
export declare function syncVideoFrameVisibility(page: Page, activeVideoIds: string[]): Promise<void>;
|
|
@@ -548,13 +548,21 @@ export async function ensureRenderFrameSiblings(page) {
|
|
|
548
548
|
* cache entry for a frame that never reached the page, which would otherwise
|
|
549
549
|
* short-circuit the next inject at the same frameIndex and leave the host's
|
|
550
550
|
* first visible frame blank.
|
|
551
|
+
*
|
|
552
|
+
* `restyleIds` keep their current picture and only re-copy the video's style.
|
|
551
553
|
*/
|
|
552
|
-
export async function injectVideoFramesBatch(page, updates) {
|
|
553
|
-
if (updates.length === 0)
|
|
554
|
+
export async function injectVideoFramesBatch(page, updates, restyleIds = []) {
|
|
555
|
+
if (updates.length === 0 && restyleIds.length === 0)
|
|
554
556
|
return [];
|
|
555
|
-
return withVideoFrameSources(page, updates, (resolved) => injectResolvedVideoFramesBatch(page, resolved));
|
|
557
|
+
return withVideoFrameSources(page, updates, (resolved) => injectResolvedVideoFramesBatch(page, resolved, restyleIds));
|
|
556
558
|
}
|
|
557
|
-
async function injectResolvedVideoFramesBatch(page, updates) {
|
|
559
|
+
async function injectResolvedVideoFramesBatch(page, updates, restyleIds) {
|
|
560
|
+
// Build the sibling id with core's function rather than a template here,
|
|
561
|
+
// so the id the readers look up has exactly one definition.
|
|
562
|
+
const items = [
|
|
563
|
+
...updates,
|
|
564
|
+
...restyleIds.map((videoId) => ({ videoId })),
|
|
565
|
+
].map((item) => ({ ...item, frameId: renderFrameIdForRenderId(item.videoId) }));
|
|
558
566
|
return await page.evaluate(
|
|
559
567
|
// fallow-ignore-next-line complexity
|
|
560
568
|
async (items, visualProperties, colorGradingSourceHiddenAttr) => {
|
|
@@ -615,6 +623,9 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
615
623
|
continue;
|
|
616
624
|
let img = video.nextElementSibling;
|
|
617
625
|
const hasImg = img !== null && img.classList.contains("__render_frame__");
|
|
626
|
+
const restyleOnly = item.dataUri === undefined;
|
|
627
|
+
if (restyleOnly && !hasImg)
|
|
628
|
+
continue;
|
|
618
629
|
if (isVisualAncestorHidden(video)) {
|
|
619
630
|
// Don't paint a frame over a hidden host — if an existing replacement
|
|
620
631
|
// <img> is still around from when the host was visible, hide it so it
|
|
@@ -700,7 +711,7 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
700
711
|
// Publish after full decode so raster cannot race it with a reduced JPEG decode.
|
|
701
712
|
img.style.setProperty("visibility", "hidden", "important");
|
|
702
713
|
img.decoding = "sync";
|
|
703
|
-
if (img.getAttribute("src") !== item.dataUri) {
|
|
714
|
+
if (item.dataUri !== undefined && img.getAttribute("src") !== item.dataUri) {
|
|
704
715
|
if (item.crossOrigin)
|
|
705
716
|
img.crossOrigin = item.crossOrigin;
|
|
706
717
|
else
|
|
@@ -718,23 +729,21 @@ async function injectResolvedVideoFramesBatch(page, updates) {
|
|
|
718
729
|
// GSAP-controlled value.
|
|
719
730
|
video.style.setProperty("visibility", "hidden", "important");
|
|
720
731
|
video.style.setProperty("pointer-events", "none", "important");
|
|
721
|
-
|
|
732
|
+
if (!restyleOnly)
|
|
733
|
+
injectedIds.push(item.videoId);
|
|
722
734
|
}
|
|
723
735
|
if (pendingDecodes.length > 0) {
|
|
724
736
|
await Promise.all(pendingDecodes);
|
|
725
737
|
}
|
|
726
738
|
for (const img of imagesToPublish)
|
|
727
739
|
img.style.visibility = "visible";
|
|
728
|
-
if (
|
|
740
|
+
if (imagesToPublish.length > 0) {
|
|
729
741
|
const redraw = window
|
|
730
742
|
.__hf?.colorGrading?.redraw;
|
|
731
743
|
redraw?.();
|
|
732
744
|
}
|
|
733
745
|
return injectedIds;
|
|
734
|
-
},
|
|
735
|
-
// Build the sibling id with core's function rather than a template here,
|
|
736
|
-
// so the id the readers look up has exactly one definition.
|
|
737
|
-
updates.map((update) => ({ ...update, frameId: renderFrameIdForRenderId(update.videoId) })), [...MEDIA_VISUAL_STYLE_PROPERTIES], COLOR_GRADING_SOURCE_HIDDEN_ATTR);
|
|
746
|
+
}, items, [...MEDIA_VISUAL_STYLE_PROPERTIES], COLOR_GRADING_SOURCE_HIDDEN_ATTR);
|
|
738
747
|
}
|
|
739
748
|
export async function syncVideoFrameVisibility(page, activeVideoIds) {
|
|
740
749
|
await page.evaluate(
|
|
@@ -159,14 +159,19 @@ export function createVideoFrameInjector(frameLookup, config) {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
const updates = [];
|
|
162
|
+
const restyleIds = [];
|
|
162
163
|
const activeIds = new Set();
|
|
164
|
+
const isBlurSubSample = heldVideoTime !== undefined && heldVideoTime !== time;
|
|
163
165
|
if (activePayloads.size > 0) {
|
|
164
166
|
const pendingReads = [];
|
|
165
167
|
for (const [videoId, payload] of activePayloads) {
|
|
166
168
|
activeIds.add(videoId);
|
|
167
169
|
const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
|
|
168
|
-
if (lastFrameIndex === payload.frameIndex)
|
|
170
|
+
if (lastFrameIndex === payload.frameIndex) {
|
|
171
|
+
if (!isBlurSubSample)
|
|
172
|
+
restyleIds.push(videoId);
|
|
169
173
|
continue;
|
|
174
|
+
}
|
|
170
175
|
pendingReads.push(frameCache
|
|
171
176
|
.get(payload.framePath)
|
|
172
177
|
.then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })));
|
|
@@ -179,14 +184,14 @@ export function createVideoFrameInjector(frameLookup, config) {
|
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
186
|
await syncVideoFrameVisibility(page, Array.from(activeIds));
|
|
182
|
-
if (updates.length > 0) {
|
|
187
|
+
if (updates.length > 0 || restyleIds.length > 0) {
|
|
183
188
|
// Only record cache entries for videos the page actually painted.
|
|
184
189
|
// injectVideoFramesBatch skips any video whose visual ancestor is
|
|
185
190
|
// hidden (sub-comp host out-of-window) and returns the subset of ids
|
|
186
191
|
// it really wrote — recording the rest would short-circuit the next
|
|
187
192
|
// call at the same frameIndex and leave the host's first visible
|
|
188
193
|
// frame blank.
|
|
189
|
-
const injectedIds = new Set(await injectVideoFramesBatch(page, updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri }))));
|
|
194
|
+
const injectedIds = new Set(await injectVideoFramesBatch(page, updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })), restyleIds));
|
|
190
195
|
for (const update of updates) {
|
|
191
196
|
if (injectedIds.has(update.videoId)) {
|
|
192
197
|
lastInjectedFrameByVideo.set(update.videoId, update.frameIndex);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.205",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"linkedom": "^0.18.12",
|
|
51
51
|
"puppeteer": "^25.8.0",
|
|
52
52
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
53
|
+
"@hyperframes/parsers": "^0.8.205",
|
|
54
|
+
"@hyperframes/core": "^0.8.205"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|