Skip to content

feat(registry): blur an element by marking it, not by calling a function - #4045

Merged
vanceingalls merged 2 commits into
mainfrom
feat/motion-blur-declarative-attribute
Sep 19, 2026
Merged

vanceingalls merged 2 commits into
mainfrom
feat/motion-blur-declarative-attribute

Conversation

@miga-heygen

Copy link
Copy Markdown
Contributor

Follows #4038, now merged, which rewrote the shutter snippet this PR extends.

Vance asked the architectural question on #4038: should this be an effect you put on an element rather than a component you install? The effect already is element-level, but the function form has an ordering contract, attachMotionBlur(el, tl) has to run after every tween is built and before the composition registers its timeline, and getting it wrong makes the blur silently do nothing. An attribute has no order to get wrong. This PR adds it.

The contract

<div id="slam" data-hf-motion-blur></div>
<div id="whip" data-hf-motion-blur='{"samplesPerFrame": 4}'></div>

Empty means defaults. Anything else is a JSON object of the same options attachMotionBlur takes. attachMotionBlur stays exported for targets built at runtime.

How an element finds its timeline

An attribute cannot name a timeline, so the snippet resolves one: the element's nearest [data-composition-id] ancestor names the key, and the timeline is window.__timelines[key]. One function, claim(el), is the only thing that attaches, and it answers whether the element still needs a trigger. Two triggers call it:

  • A property accessor on window.__timelines whose setter wraps the registry in a Proxy. The Proxy's set trap sweeps on every per-key registration. This is the trigger that attaches before the renderer captures frame zero, which is the only moment that matters for a render.
  • A bounded poll, 32 ms by 250 ticks, about eight seconds, that reads window.__timelines[key] directly and needs no cooperation from whoever wrote it.

The poll is not belt and braces. A registry reference captured before the snippet installs bypasses the trap completely, and a composition that mounts its DOM after registering its timeline has no marked element to sweep at trap time. Both are verified in Chrome below. The poll runs its whole budget every time rather than stopping the first tick nothing is pending, because on an ordinary page the top-level target is claimed on the registration write, so an early exit kills the poll one tick in, before a late composition ever arrives.

A marked element whose composition never registers a timeline warns once at the deadline, by count, rather than failing silently.

Refusals, each by name

Case Behaviour
Value is not JSON Warns with the raw text, does not attach
Value is null, a number, a string or an array Warns: all four parse as JSON and none is a set of options
An option key that is not one of the four Warns with the key. A misspelling is the likeliest typo in a JSON object, and reading it as defaults is the silent no-op this attribute exists to remove
Target inside another target Warns and refuses. The inner group is inserted into the live outer element, so the outer target's style replay walks a subtree its cursor no longer matches and desyncs on the next resize. Refusing is simpler than reconciling, and it matches the existing guidance to blur the element that moves rather than a container
A second timeline registered under the same key Warns once. The copies still follow the first

Parsing runs before the timeline lookup, so a malformed value is reported as malformed instead of being hidden behind "no composition registered a timeline". Every refusal is terminal and reported once, not once per poll tick.

Copies are stripped of data-hf-motion-blur, data-composition-id and data-fps, on the clone and every descendant. The first would make a copy a target of its own; the other two matter because resolveFps also has a document-wide fallback and copies are inserted before the original, so a copy would win it.

Two fixes to the primitive this PR made load-bearing

Frame rate is now resolved from the target's own composition. resolveFps did a document-wide querySelector("[data-composition-id][data-fps]") while the timeline came from the element's NEAREST root, so two compositions on one page shared whichever rate appeared first in the document. That was survivable while every caller could pass fps explicitly; the declarative form has no per-call argument, so it is not survivable now. Measured in Chrome with a 60 fps composition ahead of a 30 fps one: the shutter window spans 48.62 px of travel, which is the 30 fps window, where the document-wide answer gives 24.30.

The sweep is bounded per element. The write trap runs the sweep inside the author's window.__timelines[id] = tl statement, so anything thrown while blurring one element took the rest of their composition script with it. Each element's claim is now wrapped, and a failure marks that element and warns. This is what makes the second fix safe: a marked <html> threw a TypeError out of the first sweep, because document.documentElement.parentNode is the Document and has no closest, and the throw meant neither trigger was ever installed.

Skill reference

skills/hyperframes-animation/references/motion-blur.md, plus one routing row in that SKILL.md, mirroring how color grading is surfaced. It leads with when NOT to blur, because the attribute makes blurring everything trivial and blur on motion that was never fast enough makes a video worse. It carries the two routes and when each applies, the option table, the silent-no-op failure modes, the cost, and the measured shutter numbers.

It also records that skills/music-to-video/references/templates/logo-split-lockup-pulse/index.html carries a self-contained fork of the pre-#4038 feOffset shutter and calls it with axis, blurMax and blurScale, options the current primitive does not have. It still works, and converting it is a visual behaviour change to a music-video template that wants its own check, so this PR documents it rather than changing it.

The reference also documents one failure mode this PR does not fix: a target reparented after attaching leaves its group behind in the old parent. That is true of the imperative form too and predates this change, so it is written down rather than patched here.

Verification

42 tests in packages/cli/src/registry/motionBlurShutter.browser.test.ts, and every fix mutation-tested. Worth naming one mutation that did NOT fail the suite: changing the nesting branch from refuse(); return true back to return refuse(...). That looked like a vacuous test and is not. With the check sitting below the handled(el) early return, the null return is harmless because the next sweep returns early; the behaviour is carried by the ORDERING, and moving the check back above handled(el) does fail the suite.

In Chrome, not only happy-dom:

Fixture Result
The demo plate, declarative, at t = 0.5 10 groups, 17 copies, 17 distinct transforms, 0 stray targets, no console output
The example composition, still imperative Identical
Composition registers its timeline, then inserts its DOM 1 group, then 2 at 1.4 s, no warnings
Registry reference captured before the snippet installs 0 groups synchronously, 1 group with 17 copies one poll tick later, no warnings
60 fps composition ahead of a 30 fps one, target in the second Window spans 48.62 px, the 30 fps answer, against 24.30 for 60
A misspelled option key, and a non-numeric option value No group, one warning naming the option

The demo plate is now declarative and the example composition and both shutter-slam files stay imperative, so both forms have live coverage.

One thing the test suite cannot show

The verbatim-copy test previously ended the snippet at the first }; at body indent, which is attachMotionBlur's own closer. Every line added after it was dropped from all four inlined copies and the test still passed, because the extractor truncated both sides identically. A real browser showing zero groups is what caught it. The snippet is now bracketed by /* SHUTTER_SNIPPET_START */ and /* SHUTTER_SNIPPET_END */ and both the inliner and the test key off those. A structural delimiter cannot work here: in a copy the snippet shares one IIFE with that composition's own timeline code, so no brace or closer can tell the two apart.

— Miga

@mintlify

mintlify Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Sep 19, 2026, 5:41 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

attachMotionBlur(el, tl) has an ordering contract: it has to run after every
tween is defined and before the composition registers its timeline. Getting it
wrong makes the blur silently do nothing, which is the worst shape a failure can
take in a renderer nobody watches. An attribute has no order to get wrong.

data-hf-motion-blur on any element is now the contract. Empty means defaults;
anything else is a JSON object of the same options. The element finds its own
timeline: its nearest [data-composition-id] ancestor names the key, and the
snippet reads window.__timelines[key]. attachMotionBlur stays exported for a
target built after the page has settled.

claim(el) is the only thing that attaches, and two triggers call it. An accessor
on window.__timelines wraps the registry in a Proxy whose set trap sweeps on
every per-key registration, which is what attaches before the renderer captures
frame zero. A bounded poll, 32 ms by 250 ticks, reads the registry directly and
needs no cooperation from whoever wrote it. The poll is not belt and braces: a
registry reference captured before the snippet installs bypasses the trap
outright, and a composition that mounts its DOM after registering its timeline
has no marked element to sweep at trap time. It runs its whole budget every time
rather than stopping the first tick nothing is pending, because on an ordinary
page the top-level target is claimed on the registration write, so an early exit
kills the poll one tick in, before any late composition arrives.

Every refusal is by name, once, and never silent: a value that is not JSON, a
value that parses but is not an options object, an option name that is not one
of the four, an option value that is not a finite number, a target inside
another target, and a second timeline registered under one key. Parsing runs
before the timeline lookup, so a malformed value is reported as malformed rather
than as a composition that never registered.

Two properties of the primitive this change made load-bearing:

- Frame rate is resolved from the target's own composition root, not from the
  first root in the document. The document-wide lookup was survivable while
  every caller could pass fps explicitly; the declarative form has no per-call
  argument. Two compositions on one page at different rates now each get their
  own shutter window, and one call naming both says it cannot.
- An attach is all-or-nothing. The mark, the copies and the resize observer go
  in together and come back out together, so a throw partway through leaves the
  element untouched rather than marked blurred with copies in the page and no
  tracker driving them. The sweep is also bounded per element, because the write
  trap runs it inside the author's own registration statement.

Copies are stripped of data-hf-motion-blur, data-composition-id and data-fps,
on the clone and every descendant: the first would make a copy a target of its
own, and the other two would let a copy win the fps lookup, since copies are
inserted before the original.

skills/hyperframes-animation/references/motion-blur.md is the agent-facing half,
mirroring how colour grading is surfaced. It leads with when NOT to blur,
because the attribute makes blurring everything trivial and a smear on motion
that was never fast enough reads as a soft, cheap render.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
Resolve skills-manifest.json by regenerating it from skill content
(bun run --cwd packages/cli gen:skills-manifest). The file is derived,
so the merge resolution is to recompute rather than hand-merge hashes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@vanceingalls
vanceingalls merged commit 752b552 into main Sep 19, 2026
54 checks passed
@vanceingalls
vanceingalls deleted the feat/motion-blur-declarative-attribute branch September 19, 2026 05:55

This branch was successfully deployed

1 active deployment
staging - docs — be7cdb9b Deployed Sep 19, 2026 by mintlify[bot]
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.

3 participants