fix: speed up video frame injection renders (#596)

This commit is contained in:
Miguel Ángel
2026-05-02 05:11:05 +02:00
committed by GitHub
parent 04bd56a7ae
commit 4750a981dd
5 changed files with 236 additions and 101 deletions
+53 -2
View File
@@ -74,6 +74,47 @@ export interface CaptureSession {
// Circular buffer for browser console messages dumped on render failure diagnostics.
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
promise.then(
() => undefined,
() => undefined,
),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve();
}, CAPTURE_SESSION_CLOSE_TIMEOUT_MS);
}),
]);
if (timer) clearTimeout(timer);
return !timedOut;
}
function forceKillBrowserProcess(browser: Browser): void {
const browserProcess = (
browser as unknown as {
process?: () => { kill: (signal?: NodeJS.Signals) => boolean; killed?: boolean } | null;
}
).process?.();
if (browserProcess && !browserProcess.killed) {
try {
browserProcess.kill("SIGKILL");
} catch {
// Best-effort cleanup after Puppeteer close has already timed out.
}
}
try {
browser.disconnect();
} catch {
// Best-effort cleanup after Puppeteer close has already timed out.
}
}
export async function createCaptureSession(
serverUrl: string,
@@ -674,11 +715,21 @@ export async function closeCaptureSession(session: CaptureSession): Promise<void
// but browserReleased=false → second call no-ops on page and retries browser.
// This matches the orchestrator's intent for HDR cleanup.
if (!session.pageReleased && session.page) {
await session.page.close().catch(() => {});
const pageClosed = await waitForCloseWithTimeout(session.page.close());
if (!pageClosed) {
console.warn("[FrameCapture] Timed out closing page; forcing browser process shutdown");
forceKillBrowserProcess(session.browser);
}
session.pageReleased = true;
}
if (!session.browserReleased && session.browser) {
await releaseBrowser(session.browser, session.config);
const browserClosed = await waitForCloseWithTimeout(
releaseBrowser(session.browser, session.config),
);
if (!browserClosed) {
console.warn("[FrameCapture] Timed out closing browser; forcing browser process shutdown");
forceKillBrowserProcess(session.browser);
}
session.browserReleased = true;
}
session.isInitialized = false;
@@ -446,13 +446,15 @@ export async function injectVideoFramesBatch(
}
}
img.decoding = "sync";
img.src = item.dataUri;
pendingDecodes.push(
img
.decode()
.catch(() => undefined)
.then(() => undefined),
);
if (img.getAttribute("src") !== item.dataUri) {
img.src = item.dataUri;
pendingDecodes.push(
img
.decode()
.catch(() => undefined)
.then(() => undefined),
);
}
img.style.opacity = String(computedOpacity);
img.style.visibility = "visible";
// Hide the native <video> with visibility only — never clobber inline
@@ -14,7 +14,16 @@ import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotSe
import { type BeforeCaptureHook } from "./frameCapture.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
function createFrameDataUriCache(cacheLimit: number) {
export interface VideoFrameInjectorOptions extends Partial<
Pick<EngineConfig, "frameDataUriCacheLimit">
> {
frameSrcResolver?: (framePath: string) => string | null;
}
function createFrameSourceCache(
cacheLimit: number,
frameSrcResolver?: (framePath: string) => string | null,
) {
const cache = new Map<string, string>();
const inFlight = new Map<string, Promise<string>>();
@@ -33,6 +42,9 @@ function createFrameDataUriCache(cacheLimit: number) {
}
async function get(framePath: string): Promise<string> {
const servedSrc = frameSrcResolver?.(framePath);
if (servedSrc) return servedSrc;
const cached = cache.get(framePath);
if (cached) {
remember(framePath, cached);
@@ -67,7 +79,7 @@ function createFrameDataUriCache(cacheLimit: number) {
*/
export function createVideoFrameInjector(
frameLookup: FrameLookupTable | null,
config?: Partial<Pick<EngineConfig, "frameDataUriCacheLimit">>,
config?: VideoFrameInjectorOptions,
): BeforeCaptureHook | null {
if (!frameLookup) return null;
@@ -75,7 +87,7 @@ export function createVideoFrameInjector(
32,
config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
);
const frameCache = createFrameDataUriCache(cacheLimit);
const frameCache = createFrameSourceCache(cacheLimit, config?.frameSrcResolver);
const lastInjectedFrameByVideo = new Map<string, number>();
return async (page: Page, time: number) => {