feat(registry): blur an element by marking it, not by calling a function - #4045
Merged
Merged
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 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>
miga-heygen
force-pushed
the
feat/motion-blur-declarative-attribute
branch
from
September 17, 2026 20:17
af5bf14 to
336aa64
Compare
vanceingalls
approved these changes
Sep 17, 2026
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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Empty means defaults. Anything else is a JSON object of the same options
attachMotionBlurtakes.attachMotionBlurstays 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 iswindow.__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:window.__timelineswhose setter wraps the registry in a Proxy. The Proxy'ssettrap 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.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
null, a number, a string or an arrayParsing 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-idanddata-fps, on the clone and every descendant. The first would make a copy a target of its own; the other two matter becauseresolveFpsalso 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.
resolveFpsdid a document-widequerySelector("[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 passfpsexplicitly; 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] = tlstatement, 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 aTypeErrorout of the first sweep, becausedocument.documentElement.parentNodeis the Document and has noclosest, 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.htmlcarries a self-contained fork of the pre-#4038feOffsetshutter and calls it withaxis,blurMaxandblurScale, 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 fromrefuse(); return trueback toreturn refuse(...). That looked like a vacuous test and is not. With the check sitting below thehandled(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 abovehandled(el)does fail the suite.In Chrome, not only happy-dom:
The demo plate is now declarative and the example composition and both
shutter-slamfiles 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 isattachMotionBlur'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