Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 9 additions & 7 deletions packages/engine/src/services/screenshotService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions packages/engine/src/services/videoFrameInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>>();

Expand All @@ -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);
Expand Down Expand Up @@ -67,15 +79,15 @@ function createFrameDataUriCache(cacheLimit: number) {
*/
export function createVideoFrameInjector(
frameLookup: FrameLookupTable | null,
config?: Partial<Pick<EngineConfig, "frameDataUriCacheLimit">>,
config?: VideoFrameInjectorOptions,
): BeforeCaptureHook | null {
if (!frameLookup) return null;

const cacheLimit = Math.max(
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) => {
Expand Down
87 changes: 50 additions & 37 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
collectVideoMetadataHints,
collectVideoReadinessSkipIds,
createCaptureCalibrationConfig,
createCompiledFrameSrcResolver,
estimateMeasuredCaptureCostMultiplier,
estimateCaptureCostMultiplier,
extractStandaloneEntryFromIndex,
Expand Down Expand Up @@ -125,6 +126,22 @@ describe("shouldUseStreamingEncode", () => {
});
});

describe("createCompiledFrameSrcResolver", () => {
it("maps extracted frame paths under compiledDir to encoded server URLs", () => {
const resolver = createCompiledFrameSrcResolver("/tmp/hf job/compiled");

expect(
resolver("/tmp/hf job/compiled/__hyperframes_video_frames/video 1/frame_00001.jpg"),
).toBe("/__hyperframes_video_frames/video%201/frame_00001.jpg");
});

it("returns null for paths outside compiledDir", () => {
const resolver = createCompiledFrameSrcResolver("/tmp/hf-job/compiled");

expect(resolver("/tmp/hf-job/video-frames/frame_00001.jpg")).toBeNull();
});
});

describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => {
const tempDirs: string[] = [];
afterEach(() => {
Expand Down Expand Up @@ -337,15 +354,6 @@ describe("collectVideoMetadataHints", () => {

describe("resolveRenderWorkerCount", () => {
const cfg = { ...createConfig(), coresPerWorker: 100 };
const audio = {
id: "narration",
src: "narration.wav",
start: 0,
end: 3,
mediaStart: 0,
layer: 9,
type: "audio" as const,
};

it("reduces auto workers for expensive capture workloads", () => {
const log = {
Expand All @@ -363,7 +371,6 @@ describe("resolveRenderWorkerCount", () => {
hasShaderTransitions: true,
renderModeHints: { recommendScreenshot: false, reasons: [] },
},
{ videos: [], audios: [audio] },
log,
);

Expand All @@ -387,7 +394,6 @@ describe("resolveRenderWorkerCount", () => {
hasShaderTransitions: true,
renderModeHints: { recommendScreenshot: false, reasons: [] },
},
{ videos: [], audios: [audio] },
log,
);

Expand All @@ -404,43 +410,50 @@ describe("resolveRenderWorkerCount", () => {
hasShaderTransitions: false,
renderModeHints: { recommendScreenshot: false, reasons: [] },
},
{ videos: [], audios: [] },
undefined,
{ multiplier: 4, reasons: ["calibration-p95=2400ms"] },
);

expect(workers).toBe(1);
});
});

describe("estimateCaptureCostMultiplier", () => {
it("weights shader transitions, media, and render mode hints", () => {
const cost = estimateCaptureCostMultiplier(
{
hasShaderTransitions: true,
renderModeHints: {
recommendScreenshot: true,
reasons: [{ code: "requestAnimationFrame", message: "raw rAF" }],
},
},
it("keeps baseline auto workers after screenshot fallback when measured capture is cheap", () => {
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};

const workers = resolveRenderWorkerCount(
180,
undefined,
{ ...cfg, forceScreenshot: true },
{
videos: [],
audios: [
{
id: "narration",
src: "narration.wav",
start: 0,
end: 3,
mediaStart: 0,
layer: 9,
type: "audio" as const,
},
],
hasShaderTransitions: false,
renderModeHints: { recommendScreenshot: false, reasons: [] },
},
log,
{ multiplier: 1, reasons: [], p95Ms: 180 },
);

expect(cost.multiplier).toBe(4.75);
expect(cost.reasons).toEqual(["shader-transitions", "requestAnimationFrame", "1 audio"]);
expect(workers).toBe(6);
expect(log.warn).not.toHaveBeenCalled();
});
});

describe("estimateCaptureCostMultiplier", () => {
it("weights shader transitions and render mode hints without charging static media cost", () => {
const cost = estimateCaptureCostMultiplier({
hasShaderTransitions: true,
renderModeHints: {
recommendScreenshot: true,
reasons: [{ code: "requestAnimationFrame", message: "raw rAF" }],
},
});

expect(cost.multiplier).toBe(4);
expect(cost.reasons).toEqual(["shader-transitions", "requestAnimationFrame"]);
});
});

Expand Down
Loading
Loading