fix(engine): long films show progress during the effect check and never wait minutes on it (#6697)

* fix(engine): long films start capturing at once instead of after a full effect scan

* fix(engine): give the effect scan two minutes, under the app's stall limit

* fix(engine): show effect scan progress so a long scan reads as preparing, not stalled
This commit is contained in:
Miguel Ángel
2026-10-29 10:35:12 +00:00
committed by GitHub
parent 0da46832ef
commit e1a8c7f173
5 changed files with 113 additions and 35 deletions
@@ -1309,6 +1309,7 @@ async function cssEffectRiskFrames(
fps,
totalFrames,
totalFrames * MAX_RISK_FRAME_FRACTION,
{ onProgress: session.options.onEffectScanProgress },
);
return live && withFramesAfterCallback(live, scan.callbackFrom, fps, totalFrames);
} catch (error) {
@@ -102,6 +102,52 @@ const frames = (live: Set<number> | null) => [...(live ?? [])].sort((a, b) => a
const range = (from: number, count: number) => Array.from({ length: count }, (_, i) => from + i);
describe("scanCssEffectLiveFrames", () => {
it("stops after one chunk when the whole scan would hold back the first frame too long", async () => {
const seeks: number[] = [];
const page = makePage(between(0, 0));
const evaluate = page.evaluate.bind(page);
page.evaluate = (async (...args: Parameters<Page["evaluate"]>) => {
seeks.push((args[1] as number[]).length);
return evaluate(...args);
}) as Page["evaluate"];
let clock = 0;
// 24 ms a frame, what a heavy page costs: 40,219 frames would take 16 minutes.
const now = () => (clock += 240 * 24);
expect(
await scanCssEffectLiveFrames(page, 60, 40_219, 40_219, { budgetMs: 60_000, now }),
).toBeNull();
expect(seeks).toEqual([240]);
});
it("reports the frames scanned after each chunk", async () => {
const seen: Array<[number, number]> = [];
await scanCssEffectLiveFrames(makePage(between(0, 0)), 10, 500, 500, {
onProgress: (scanned, total) => seen.push([scanned, total]),
});
expect(seen).toEqual([
[240, 500],
[480, 500],
[500, 500],
]);
});
it("scans to the end when the whole scan fits the budget", async () => {
let clock = 0;
const now = () => (clock += 240);
const live = await scanCssEffectLiveFrames(
makePage(between(2, 4)),
10,
1000,
1000,
60_000,
now,
);
expect(frames(live)).toEqual(range(20, 20));
});
it("returns the frames where the effect element shows", async () => {
const live = await scanCssEffectLiveFrames(makePage(between(2, 4)), 10, 100, 100);
expect(frames(live)).toEqual(range(20, 20));
+56 -35
View File
@@ -9,6 +9,9 @@ import { quantizeTimeToFrame } from "@hyperframes/core";
/** Frames per page round trip while scanning; a scan is one in-page loop per chunk. */
const SCAN_CHUNK_FRAMES = 240;
/** Longest the scan may hold back the first frame; above it the whole render captures by screenshot. */
const SCAN_BUDGET_MS = 120_000;
/**
* Above this share of risk frames the second browser and the switching cost outweigh the fast
* frames, so the whole render captures by screenshot as it always has.
@@ -26,53 +29,71 @@ const PIXELS_1080P = 1920 * 1080;
/** Risk ranges closer than this many frames are joined, so capture never alternates modes. */
export const RISK_RANGE_HYSTERESIS_FRAMES = 12;
async function scanChunk(page: Page, times: number[]): Promise<number[]> {
return page.evaluate(async (ts: number[]): Promise<number[]> => {
type HfWindow = Window & {
__hf?: { seek?: (t: number) => void };
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfCssFxTargets?: Element[];
};
const w = window as HfWindow;
const targets = w.__hfCssFxTargets ?? [];
// A hidden element still shows a descendant that sets `visibility: visible` itself.
const paints = (el: Element): boolean =>
getComputedStyle(el).visibility !== "hidden" ||
Array.from(el.querySelectorAll("*")).some(
(d) => getComputedStyle(d).visibility === "visible",
);
const shows = (el: Element): boolean => {
for (let n: Element | null = el; n; n = n.parentElement) {
const cs = getComputedStyle(n);
if (cs.display === "none" || Number(cs.opacity) === 0) return false;
}
return paints(el);
};
const out: number[] = [];
for (let i = 0; i < ts.length; i++) {
w.__hf?.seek?.(ts[i] as number);
await w.__hfWaitForSeekCompletion?.();
if (targets.some(shows)) out.push(i);
}
return out;
}, times);
}
interface ScanOptions {
budgetMs?: number;
now?: () => number;
onProgress?: (scannedFrames: number, totalFrames: number) => void;
}
/**
* Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
* Null once more than `maxFrames` frames qualify.
* Null once more than `maxFrames` frames qualify, or once the whole scan would take over `budgetMs`.
*/
export async function scanCssEffectLiveFrames(
page: Page,
fps: number,
totalFrames: number,
maxFrames: number,
{ budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress }: ScanOptions = {},
): Promise<Set<number> | null> {
const live = new Set<number>();
for (let from = 0; from < totalFrames; from += SCAN_CHUNK_FRAMES) {
const started = now();
for (let from = 0; from < totalFrames && live.size <= maxFrames; from += SCAN_CHUNK_FRAMES) {
const to = Math.min(totalFrames, from + SCAN_CHUNK_FRAMES);
const times: number[] = [];
for (let f = from; f < Math.min(totalFrames, from + SCAN_CHUNK_FRAMES); f++) {
times.push(quantizeTimeToFrame(f / fps, fps));
for (let f = from; f < to; f++) times.push(quantizeTimeToFrame(f / fps, fps));
for (const i of await scanChunk(page, times)) live.add(from + i);
onProgress?.(to, totalFrames);
const projectedMs = ((now() - started) / to) * totalFrames;
if (to < totalFrames && projectedMs > budgetMs) {
console.error(
`[engine] fast capture: the effect scan of ${totalFrames} frames would take ` +
`${Math.round(projectedMs / 1000)} s, over its ${budgetMs / 1000} s budget`,
);
return null;
}
const hits = await page.evaluate(async (ts: number[]): Promise<number[]> => {
type HfWindow = Window & {
__hf?: { seek?: (t: number) => void };
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfCssFxTargets?: Element[];
};
const w = window as HfWindow;
const targets = w.__hfCssFxTargets ?? [];
// A hidden element still shows a descendant that sets `visibility: visible` itself.
const paints = (el: Element): boolean =>
getComputedStyle(el).visibility !== "hidden" ||
Array.from(el.querySelectorAll("*")).some(
(d) => getComputedStyle(d).visibility === "visible",
);
const shows = (el: Element): boolean => {
for (let n: Element | null = el; n; n = n.parentElement) {
const cs = getComputedStyle(n);
if (cs.display === "none" || Number(cs.opacity) === 0) return false;
}
return paints(el);
};
const out: number[] = [];
for (let i = 0; i < ts.length; i++) {
w.__hf?.seek?.(ts[i] as number);
await w.__hfWaitForSeekCompletion?.();
if (targets.some(shows)) out.push(i);
}
return out;
}, times);
for (const i of hits) live.add(from + i);
if (live.size > maxFrames) break;
}
return live.size > maxFrames ? null : live;
}
+1
View File
@@ -246,6 +246,7 @@ export interface CaptureOptions {
*/
deVerifySamples?: number;
deFrameFallback?: boolean;
onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void;
}
export interface CaptureVideoMetadataHint {
@@ -3683,6 +3683,15 @@ async function executeRenderPipeline(input: {
// sample frame indices from this so they land inside the drained range.
compositionDurationSeconds: job.duration,
deFrameFallback: true,
// A line per scanned chunk, so a long effect scan reads as preparing, not as a stall.
onEffectScanProgress: (scanned, total) =>
updateJobStatus(
job,
"rendering",
`Checking effects, ${scanned} of ${total} frames`,
25,
onProgress,
),
requiresWebGpu: compositionRequiresWebGpu(compiled.html),
// Live route for Chrome memory (spec §5 Phase −1). The aggregate route
// through CapturePerfSummary only exists on success; a mid-capture