feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension (#1919)

* feat(engine): drawElementImage capture service

* chore(ci): ignore drawElementService exports pending upstack consumers

Fallow's per-PR audit diffs against the merge base, so the bottom of the
fast-capture stack (#1917) sees drawElementService's exports as unused —
their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports
entry documents this and can be dropped once #1919 merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(engine): 3D projection + compositor-effect risk gate

* fix(engine): gate filter drop-shadow wherever blur gates (review)

detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only
detected blur( in its three scan paths — a drop-shadow comp stayed on the
fast path despite the gate's own correctness contract. Detect drop-shadow(
in computed styles, stylesheet rules, and GSAP tween vars, pinned by a
focused test that runs the real page-side closure against a DOM shim
(computed / stylesheet / tween coverage + blur regression + effect-free
null).

Addresses miguel-heygen's blocker on #1918.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension

# Conflicts:
#	packages/engine/src/services/screenshotService.ts

* fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review)

Addresses miguel-heygen's blockers on #1919:

- HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to
  a documented diagnostic flag: it exists for upstream-Chromium repro work
  (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect
  classes; renders under it may be damaged BY DESIGN since it bypasses gates
  whose thresholds encode measured damage. Never production; the safety-net
  blank guard also stands down under it so diagnostic frames arrive unmodified.
- armStaticDedup is now idempotent: the drawElement init path arms dedup
  before canvas injection, then initializeSession called it again — the
  second run overwrote the armed state with skipReason="capture_mode"
  (captureMode is "drawelement" by then), producing contradictory telemetry
  (armed frames + a skip reason), and re-ran the verification seeks on the
  fallback path. It now no-ops once staticFrames or a skip decision exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-06 16:21:12 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 4749fe5716
commit 0e58344dca
4 changed files with 754 additions and 34 deletions
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication complexity
/**
* Screenshot Service
*
@@ -43,6 +44,55 @@ export interface BeginFrameResult {
hasDamage: boolean;
}
/**
* Issue a single no-output BeginFrame and race it against `timeoutMs`.
*
* On SwiftShader, compositions with many promoted layers (multi-group nested
* opacity caption animations) can stall the FIRST BeginFrame indefinitely —
* tested to 30 minutes without completion (style-7/8/10/15-prod). The
* auto-worker calibration path catches this with its own capped protocol
* timeout, but renders with an explicit `--workers N` skip calibration and
* would hang for the full protocol timeout (and never succeed). This probe
* gives the producer a cheap liveness signal right after session init:
* `false` means route the render through screenshot capture instead.
*
* Healthy comps complete the probe in well under a second on GPU and within
* a few seconds on SwiftShader. A protocol error also resolves `false` —
* the safe direction (screenshot capture always works).
*/
export async function probeBeginFrameLiveness(
page: Page,
timeoutMs: number,
// BeginFrame frameTimeTicks must be monotonic per session. The capture loop
// sends `session.beginFrameTimeTicks + frameIndex * interval`, where the
// base carries a 10-interval cushion above the warmup loop's last tick —
// callers probing an initialized session should pass a tick INSIDE that
// cushion (e.g. base 5·interval) so warmup < probe < first capture stays
// monotonic. Omit both params only for a session that will not issue
// further BeginFrames.
frameTimeTicks?: number,
intervalMs?: number,
): Promise<boolean> {
const client = await getCdpSession(page);
const params: { frameTimeTicks?: number; interval?: number } = {};
if (typeof frameTimeTicks === "number") params.frameTimeTicks = frameTimeTicks;
if (typeof intervalMs === "number") params.interval = intervalMs;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
client
.send("HeadlessExperimental.beginFrame", params)
.then(() => true)
.catch(() => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* Capture a frame using HeadlessExperimental.beginFrame.
*
@@ -545,6 +595,7 @@ export async function injectVideoFramesBatch(
// shorter than the host's authored data-duration, where the runtime
// truncates visibility but the replacement <img> must hold its last
// frame) — those must NOT be skipped here.
// fallow-ignore-next-line code-duplication
const isVisualAncestorHidden = (el: HTMLElement): boolean => {
let parent = el.parentElement;
while (parent !== null && parent !== document.documentElement) {