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
318 changes: 239 additions & 79 deletions packages/core/src/runtime/captureMotionBlur/index.test.ts

Large diffs are not rendered by default.

86 changes: 57 additions & 29 deletions packages/core/src/runtime/captureMotionBlur/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ interface Config {
interface Report {
state: "on" | "off";
reason: string | null;
reasonCode: string | null;
movers: number;
/** The blurred elements, as `tag#id.class`, for diagnosing what was (not) picked. */
moverNames: string[];
Expand All @@ -76,7 +77,7 @@ type DrawElementContext = CanvasRenderingContext2D & {
};
type PaintCanvas = HTMLCanvasElement & { requestPaint: () => void };
type CompositeWindow = Window & {
__hfMotionBlur?: { report: () => Report };
__hfMotionBlur?: { report: () => Report; probe: () => Report; suspend: () => void };
__hf_page_composite_pending?: boolean;
__hf_page_composite_resolve?: () => boolean;
};
Expand Down Expand Up @@ -127,15 +128,16 @@ export const trackAcceleratedCanvases = installAcceleratedCanvasTracker;

const isAccelerated: DefectEnv["isAccelerated"] = isAcceleratedCanvas;

function unsupportedReason(root: HTMLElement): string | null {
function unsupported(root: HTMLElement): CaptureDefect | null {
const off = (window as unknown as Record<string, unknown>)[PAGE_MOTION_BLUR_OFF_GLOBAL];
if (off) return `the renderer turned it off (${String(off)})`;
if (off) return { code: "renderer-off", reason: `the renderer turned it off (${String(off)})` };
if (!supportsCaptureMotionBlur()) {
return "html-in-canvas (drawElementImage) is unavailable in this browser";
return {
code: "unsupported-browser",
reason: "html-in-canvas (drawElementImage) is unavailable in this browser",
};
}
return (
findCaptureDefect(root, { isAccelerated, backface: scanBackfaceDefect(root) })?.reason ?? null
);
return findCaptureDefect(root, { isAccelerated, backface: scanBackfaceDefect(root) });
}

function describe(el: Element): string {
Expand Down Expand Up @@ -199,6 +201,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
let failures = 0;
let failedFrames = 0;
let disabled: string | null = null;
let disabledCode: string | null = null;
// Capture failures end blur for good; a defect block lasts until the scene is scanned again.
let failedForGood = false;
const timings: number[] = [];
Expand Down Expand Up @@ -347,7 +350,8 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
countFailedFrame(err instanceof Error ? err.message : String(err));
// A render never gives up mid-video: this frame shows the plain DOM, the next wraps again.
if (rendering) unwrap();
else if (failures >= MAX_FAILURES) disable(`capture failed ${failures} times in a row`, true);
else if (failures >= MAX_FAILURES)
disable(`capture failed ${failures} times in a row`, "capture-failures", true);
}

/** This paint's half of the frame; false when the background capture is still to come. */
Expand Down Expand Up @@ -405,9 +409,10 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI

src.addEventListener("paint", onPaint);

function disable(reason: string, forGood = false): void {
function disable(reason: string, code: string, forGood = false): void {
if (disabled) return;
disabled = reason;
disabledCode = code;
failedForGood = forGood;
console.warn(`[motion-blur] off: ${reason}`);
teardown();
Expand All @@ -421,6 +426,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
function clearDefectBlock(): void {
if (!disabled || failedForGood) return;
disabled = null;
disabledCode = null;
reader = null;
src.addEventListener("paint", onPaint);
}
Expand Down Expand Up @@ -474,7 +480,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
/** Scan when the timeline is new; true when the scan found a defect (blur is then off). */
function blockedByScan(t: number): boolean {
if (!reader || host.timeline() !== scannedTimeline) discover(t);
if (scanDefect) disable(scanDefect.reason);
if (scanDefect) disable(scanDefect.reason, scanDefect.code);
return disabled !== null;
}

Expand Down Expand Up @@ -532,7 +538,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
});
} catch (err) {
swallow("runtime.captureMotionBlur.paint", err);
disable(err instanceof Error ? err.message : String(err), true);
disable(err instanceof Error ? err.message : String(err), "capture-failures", true);
return Promise.resolve();
}
}
Expand All @@ -542,6 +548,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
return {
state: disabled ? "off" : "on",
reason: disabled,
reasonCode: disabledCode,
movers: movers.length,
moverNames: movers.map(describe),
scanMs,
Expand All @@ -553,9 +560,16 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
};
}

function probe(): Report {
releaseStaleBlock();
if (!disabled) blockedByScan(host.now());
return report();
}

return {
root,
paint,
probe,
/** Paused in preview: show plain DOM so it can be selected. A render never rests. */
rest: (): boolean => {
if (rendering) return false;
Expand All @@ -574,6 +588,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI
step(true);
},
teardown,
suspend: unwrap,
report,
busy: (): boolean => pending !== null,
rediscover: () => {
Expand All @@ -585,6 +600,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI

type Instance = ReturnType<typeof createInstance>;
let instance: Instance | null = null;
let suspended = false;
let priorResolver: (() => boolean) | null = null;
let ourResolver: (() => boolean) | null = null;

Expand All @@ -606,28 +622,33 @@ export function initCaptureMotionBlur(host: CaptureMotionBlurHost): void {
return;
}
const form = htmlInCanvasForm();
const reason = unsupportedReason(host.root);
if (!form || reason) {
const why = unsupported(host.root);
if (!form || why) {
const reason = why?.reason ?? null;
console.info(`[motion-blur] off: ${reason} (Chrome ${chromeMajor() ?? "unknown"})`);
w.__hfMotionBlur = {
report: () => ({
state: "off",
reason,
movers: 0,
moverNames: [],
scanMs: 0,
shutterDeg: config.shutterDeg,
frames: 0,
meanMs: 0,
failures: 0,
failedFrames: 0,
}),
};
const report = (): Report => ({
state: "off",
reason,
reasonCode: why?.code ?? null,
movers: 0,
moverNames: [],
scanMs: 0,
shutterDeg: config.shutterDeg,
frames: 0,
meanMs: 0,
failures: 0,
failedFrames: 0,
});
w.__hfMotionBlur = { report, probe: report, suspend: suspendCaptureMotionBlur };
return;
}
instance = createInstance(host, config, form);
const current = instance;
w.__hfMotionBlur = { report: () => current.report() };
w.__hfMotionBlur = {
report: () => current.report(),
probe: () => current.probe(),
suspend: suspendCaptureMotionBlur,
};
}

/**
Expand All @@ -641,7 +662,7 @@ export function paintCaptureMotionBlur(
t: number,
options?: { engineMode?: boolean; playing?: boolean },
): Promise<void> | null {
if (!instance) return null;
if (!instance || suspended) return null;
const engineMode = options?.engineMode === true;
if (!engineMode && !options?.playing && instance.rest()) return null;
const done = instance.paint(t, engineMode);
Expand Down Expand Up @@ -678,10 +699,17 @@ function armPageComposite(): void {
w.__hf_page_composite_pending = true;
}

/** Stop wrapping and painting for the rest of the page's life; the reported decision stays. */
function suspendCaptureMotionBlur(): void {
suspended = true;
instance?.suspend();
}

/** Test-only: drop module state between cases. */
export function resetCaptureMotionBlurForTests(): void {
instance?.teardown();
instance = null;
suspended = false;
priorResolver = null;
ourResolver = null;
}
Loading
Loading