feat(core): block capture motion blur on html-in-canvas defects by vanceingalls · Pull Request #5971 · heygen-com/hyperframes · GitHub
Skip to content

feat(core): block capture motion blur on html-in-canvas defects - #5971

Open
vanceingalls wants to merge 1 commit into
feat/capture-blur-scanfrom
feat/capture-blur-check
Open

vanceingalls wants to merge 1 commit into
feat/capture-blur-scanfrom
feat/capture-blur-check

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

What

  • A defect list for html-in-canvas (htmlInCanvasDefects.ts). Before the runtime wraps a composition it checks for things html-in-canvas captures wrongly; a hit turns blur off for the whole composition and the runtime says which one. In this PR: a nested capture canvas (nesting hangs the renderer), a WebGL / WebGPU canvas in the root (captured frozen; Chrome 157 fixed it), backface-visibility: hidden inside a 3D context (drawn mirrored), an <iframe> in the root (the wrap moves the root in the document and reloads the iframe, which cannot be undone), a transform on the root (ignored by the capture) and opacity below 1 on the root. Some entries apply only to a range of Chrome versions.
  • The backface scan sees more of the page's CSS: rules nested inside a style rule (CSS nesting, with & resolved against the parent), sheets pulled in with @import (their styleSheet, read once each, depth capped, unreadable cross-origin sheets skipped), and adopted style sheets. Before, a nested or @imported rule that computes backface-visibility: hidden was missed and blur stayed on with a mirrored face.
  • A face that gets backface-visibility: hidden from an animation is a candidate too. The scan also takes the targets of running CSS and WAAPI animations (document.getAnimations(), keyframes that key backfaceVisibility) and, for an animation not instantiated yet (a delay, a class added later), any element whose computed animation-name names a @keyframes rule that sets it. Candidates are confirmed exactly like inline and stylesheet ones: computed hidden plus a 3D context. Before, an @keyframes- or element.animate()-only flip face had neither an inline declaration nor a rule declaring it, was never read, and with another ordinary mover the runtime stayed on and wrapped the root (reviewer finding on this PR). One getAnimations() pass per scan; the keyframes walk reuses the sheet walk above and the per-element read only happens when some @keyframes rule sets the property. Measured on a 5,000-element root (Chrome 152, scan time): 26 ms before and after; with an unused @keyframes rule that sets the property, 30 ms.
  • Chrome 157 support. 157 replaces <canvas layoutsubtree> with <canvas content="drawable"> plus a drawable attribute on captured children. The new @hyperframes/core/html-in-canvas subpath detects and handles both forms; blur, the VFX runtime and Studio's browser check use it. Studio's useRenderQueue now asks it whether the browser can blur.

Why

Part 6 of 9 in the capture motion blur stack: #5875 → #5967 → #5968 → #5969 → #5970 → #5971 → #5990 → #5991 → #5992. It builds on #5970.

Stack goal: opt-in capture motion blur for compositions, in Studio preview and in renders, drawn with Chrome's html-in-canvas. A wrong blurred frame is worse than a sharp one, so blur steps aside rather than draw it.

Notes

  • Shadow DOM is not scanned for backface: a hidden face inside a shadow root is not found. A flat backface-visibility: hidden used as a performance hack (no 3D context) is deliberately not blocked. The same holds when the hack arrives through an animation.
  • The engine's fast capture still uses the old html-in-canvas API and only runs on the pinned Chrome 152; it needs the same migration before the pin moves.
  • feat(core,producer): decide blur defects once per timeline, off on routes that cannot host it #5990 widens three entries of this list: a root scale / rotate / translate counts as a root transform, a root mix-blend-mode other than normal is a defect, and the iframe entry covers <object>, <embed> and defined custom elements (individual rotate axes and z translate count as 3D for the backface check).
  • The defects are checked when a frame is about to be drawn. Deciding them once per timeline, on every Chrome version, and sharp-frame-only handling of what the scan misses come in feat(core,producer): decide blur defects once per timeline, off on routes that cannot host it #5990.
  • A defect on a composition sub-tree other than the root is not specifically handled; data-motion-blur on a sub-composition is ignored.
  • A composition with an iframe in its root now stays unblurred instead of reloading the iframe on every wrap; feat(core): harden the capture motion blur runtime lifecycle #5968 made wrapping itself quiet for everything else (CSS animations, observers).

Before

k3-promo at 9.1 s, no motion blur:

Before: no motion blur

After

Same frame with capture motion blur (360° shutter): the pins, the text and the box smear along their paths.

After: capture motion blur

Testing

  • htmlInCanvasDefects.test.ts: every defect, with Chrome versions on both sides of each version bound, the iframe on every version, the nested-rule / & / @import / adopted-sheet cases (a self-importing sheet is read once; an unreadable one is skipped; a chain past the depth cap stops), and the first-match order. The animation-derived candidates: a face hidden by @keyframes (named, in a list of names, or not matching any name), a face a running animation keys to hidden (a non-hidden keyframe ignored, a target outside the root ignored).
  • htmlInCanvas.test.ts: form detection and marking for both the layoutsubtree and drawable forms; vfx.test.ts (the VFX runtime uses the helper); runtime tests for blur staying off on each defect, including an iframe present at init and one added later; Studio useRenderQueueMotionBlur.test.tsx.
  • bun run build, tsc --noEmit in core and studio, and the touched packages' tests pass; comment ratchet against feat(core,engine): track accelerated canvases before any page script runs #5970's branch passes; no package size budget changes (core stays under the budget feat(core,engine): track accelerated canvases before any page script runs #5970 set).

🤖 Generated with Claude Code

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta review at full head13842a175e0315b9cbff6dab34932fbd51f0adb7, relative to8bb0fe5e.

Audited runtime probe→CLI collection before grid→finding/fixHint propagation, warning deduplication and first-completed-frame engine diagnostic. Probe scans without capture/wrap and restores the current time on its successful path. Unsupported browser/no movers stay informational; a disabled requested effect becomes a warning.

Nonblocking: checkMotionBlur.ts:23–24 suggests moving WebGL/WebGPU to a sub-composition without opt-in. The preceding PR checks all canvases in the same document, so an ordinary inlined sub-composition still blocks the root. Clarify a separate/flattened render or keep the drop-blur alternative.

The inherited #5875 pose and #5967 timeout findings remain; this delta approval does not clear the complete stack. Saved/fresh prior human reviews and inline comments were empty. Exact-head hosted checks pass/skip; the private focused core run passed124 authored tests. Inspected child regression logs execute3 visual/audio cases, not every new unit test. Browser-version screenshots, HDR output, worker parity, colour and performance measurements were not independently rerun. No provider call, merge or release.

— Magi (Home)
Verdict: APPROVE
Reasoning: No additional blocking defect found in this change relative to its stack parent; the foundation fixes remain required.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking: the accelerated-canvas guard from #5970 scans root.ownerDocument, so moving this effect to an ordinary inlined sub-composition still disables blur on the root. Please specify a separately rendered/flattened effect, or retain the drop-blur alternative; the current hint implies an in-document move is sufficient.

@vanceingalls
vanceingalls marked this pull request as draft October 20, 2026 21:58
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-scan branch from 8bb0fe5 to 4e120e5 Compare October 21, 2026 01:55
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-check branch from 13842a1 to 2d4f676 Compare October 21, 2026 01:55
@vanceingalls vanceingalls changed the title feat(cli): tell authors in check why motion blur is off feat(core): block capture motion blur on html-in-canvas defects Oct 21, 2026
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-scan branch from 4e120e5 to 793f8a3 Compare October 21, 2026 03:53
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-check branch from 2d4f676 to 92598cf Compare October 21, 2026 03:53
@vanceingalls
vanceingalls marked this pull request as ready for review October 21, 2026 04:16
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-scan branch from 793f8a3 to d8d7200 Compare October 21, 2026 05:18
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-check branch from 92598cf to a03da4f Compare October 21, 2026 05:18

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head a03da4f7d8af648e4dd020030ea1f844333ab876 and composed tip d5e218c7565b433df887c803fa4ad5de20c44b09.

Both html-in-canvas forms and VFX/Studio consumers are accounted for; nested/import/adopted stylesheet support is explicit. One detector gap remains at packages/core/src/htmlInCanvasDefects.ts:234–244.

P2 blocker: candidate discovery only includes inline backface declarations or selectors from CSSStyleRules that literally declare hidden. A normal DOM face whose backface-visibility:hidden is supplied by @Keyframes (or WAAPI) has neither, so its computed hidden/3D state is never read. Exact own-head control: equivalent inline face detected; animated-only face missed. At final tip both timeline scan and facesNow remain false, and with another ordinary mover the real runtime stays on and wraps the root in CANVAS. This bypasses the stated plain-frame safeguard for the known mirrored-backface capture defect. Include animation-derived candidates or a conservative guard and test scan/backstop with that case.

Witness uses normal DOM .face{animation:turn 1s both} with hidden backface/rotateY in keyframes, no inline backface or ordinary selector declaration. Computed style/Canvas boundaries are controlled; this proves detector/runtime policy, not a newly measured Chrome pixel failure. The known mirrored capture behavior is the PR's own premise.

196 authored core tests and44 extra WAAPI/CLI tests pass locally; own-head independent positive passes/animated negative fails, and final-runtime negative fails. init.blurHold could not load due cached generated parser export. Exact-head child regression executes3 generic cases; broad unit/pixel execution is not established by its skipped checks. Existing reviews/inline comments read; none covers this new witness.

— Magi / desktop-review

Verdict: REQUEST CHANGES
Reasoning: An ordinary animated hidden 3D face escapes both the defect scan and the later backstop, allowing the capture path that the guard is intended to reject.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 blocker: candidate discovery only includes inline backface declarations or selectors from CSSStyleRules that literally declare hidden. A normal DOM face whose backface-visibility:hidden is supplied by @Keyframes (or WAAPI) has neither, so its computed hidden/3D state is never read. Exact own-head control: equivalent inline face detected; animated-only face missed. At final tip both timeline scan and facesNow remain false, and with another ordinary mover the real runtime stays on and wraps the root in CANVAS. This bypasses the stated plain-frame safeguard for the known mirrored-backface capture defect. Include animation-derived candidates or a conservative guard and test scan/backstop with that case.

html-in-canvas captures a few things wrongly, and a wrong blurred frame is
worse than a sharp one. Add a defect list the runtime checks before it wraps
a composition: a WebGL or WebGPU canvas (captured frozen), backface-hidden
faces in a 3D context (drawn mirrored), an iframe (reloaded by the wrap and
not recoverable), a transform or opacity below 1 on the root (ignored by the
capture) and a canvas that is already a capture canvas (nesting hangs the
renderer). Any hit turns blur off for the whole composition and the runtime
says which one.

The backface scan reads rules nested inside a style rule (CSS nesting),
sheets pulled in with @import and adopted style sheets. Shadow DOM stays a
documented limit.

A face whose backface-visibility: hidden arrives through an animation counts
too: the scan also takes the elements of running CSS or WAAPI animations that
key it, and elements naming a @Keyframes rule that does, as candidates.

Support Chrome 157's html-in-canvas API alongside the older one: a canvas
marked content="drawable" with a drawable attribute on captured children
replaces layoutsubtree. Blur, the VFX runtime and Studio's browser check
detect both through the new @hyperframes/core/html-in-canvas subpath.

Folded review fixes: R14, R9 (the iframe defect entry).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@vanceingalls
vanceingalls force-pushed the feat/capture-blur-check branch from a03da4f to 3d6c41b Compare October 21, 2026 07:39

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 3d6c41b95fe38297b778db01f73eed91a2bac20b against its actual parent d8d72006e0d837d70a7d501dd65473bd4af2a8a3 and the previously reviewed tree.

The omitted animated hidden-face candidate is now found from running CSS/WAAPI keyframes and from named stylesheet keyframes. Root containment and flat-face rejection remain. My earlier exact-source witness fails on a03da4f and passes on this repair; the inline positive remains green.

Independent verification: own-head and composed-tip synthetic computed-style controls 4/4 pass; predecessor witness remains red (inline positive passes). Exact-source authored detector suites run locally:31 own-head +47 tip tests pass, including WAAPI containment, named/multiple animations and cached-candidate retention. The computed animation-name seam is explicit because jsdom does not expand animation shorthand. These are deterministic detector controls, not fresh Chrome pixel/performance proof.

Current exact-head check snapshot has no failed or pending check. Read completed regression gate logs and executed #5991 preflight lint/format output; stacked broad-suite skips and aggregate gate success do not prove execution of the new pixel/unit suites. Author Chrome152 experiments, 26ms scan cost and package-budget margin remain attributed.

Known sampling limit remains: a short WAAPI-only face absent at every scan cannot enter the cached set. This bounded sampling residual differs from the repaired omission of ordinary animation-derived candidates; no universal detection guarantee is claimed. No new source blocker found in this successor composition. No browser/app/model call, merge or release.

— Magi / desktop-review

Verdict: APPROVE
Reasoning: The earlier animated-face candidate blocker is repaired and retained through the restacked composition; no additional blocking change was found.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants