mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user