Skip to content

feat(registry): blur every transform driver, and ship the reference case as shutter-slam - #4038

Merged
miguel-heygen merged 1 commit into
mainfrom
feat/motion-blur-transform-drivers
Sep 17, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
feat/motion-blur-transform-drivers

Conversation

@miga-heygen

Copy link
Copy Markdown
Contributor

What changed

motion-blur integrated translation only. Its output stage was one SVG filter per target whose copies were feOffset, and feOffset only translates, so a beat that only scaled or only turned sampled a displacement of zero and rendered sharp. No SVG filter primitive can give a copy its own affine transform, so the sampling loop was never the problem: the output stage was.

Copies are now DOM clones of the element in an isolation: isolate group, each at 1/N opacity with mix-blend-mode: plus-lighter, each carrying the element's resolved transform at its sample time. plus-lighter adds premultiplied colour, so N + 1 copies at 1/N sum to the average the shutter integral asks for. Reading the resolved matrix instead of a list of named GSAP properties is what makes every driver work at once: translation, scale, rotation, 3D rotation and skew arrive in the same one value, and the next driver needs no code here.

toFilterSpace is deleted. The axis option is deleted: it selected which of x and y to sample, a question the resolved matrix does not have.

Also in this PR: shutter-slam, a new catalog component. One word through six single-property beats, two translate slams, a scale punch, then a full turn about each axis, which exercises every driver the model covers in one mount. It is the reference case the shutter model was fitted to, made installable.

What I measured

Against the same After Effects export the shutter defaults were fitted to (1920x1080, 30 fps, 180 frames), per beat, previous output stage to this one:

beat no blur previous this PR delta
1 translate X 18.83 22.21 22.03 -0.18
2 translate Y 18.89 20.07 20.03 -0.04
3 scale 13.92 13.92 14.54 +0.62
4 rotate X 19.40 19.43 19.86 +0.43
5 rotate Y 18.21 18.23 19.07 +0.84
6 rotate Z 17.86 17.88 19.03 +1.15
whole clip 17.74 18.68 19.07 +0.39

Four beats that previously rendered sharp now blur. Two translate beats regress slightly, and the cause is inherent rather than tunable: the filter offset one rasterisation, so every copy was pixel-identical, where each copy is now rasterised independently at its own fractional transform and gets its own subpixel antialiasing. Arguably closer to what AE does; still a real 0.18 dB on the case the defaults were tuned against.

The 32.51 dB figure in releases/v0.8.45.md was measured over frames 1 to 72 of that export (the translating beats only) against a matched-font build. The table above is the whole 180-frame clip with a stand-in font, so it is a different measurement, not a regression from it.

The easing, which did not work out

I fitted a bezier to the reference's own bbox centres on the beat that never clips the frame, and it looked decisive: 0.0056 rms of the segment amplitude against 0.0211 for power2.inOut. Rendered through the same blur mechanism it loses:

beat power2.inOut fitted bezier
translate X 20.81 19.51
translate Y 18.67 18.16
scale 12.99 12.54
rotate X 18.43 19.05
rotate Y 17.66 18.67
whole clip 17.68 17.43

A blurred bbox centre is the shutter window's average, not the instant, so on an accelerating segment the fit was measuring its own blur. The tell is that it loses on the very beat it was fitted to. shutter-slam ships power2.inOut, which also drops a hand-rolled bezier solver from the component. Nailing the easing properly means recovering per-frame positions by deconvolving the shutter, which is separate work.

Three consequences of the new output stage, each with its own fix

  • mix-blend-mode flattens preserve-3d, so a copy cannot inherit the parent's 3D context. Each copy carries the parent's perspective as its own first transform function. The vanishing point then follows each copy's transform-origin rather than the parent's perspective-origin; those agree when the element is centred in its perspective parent, which is how a 3D beat is authored.
  • A copy is styled by nothing that selected the original, because dropping the id also drops every #id rule that gave it size, colour and font. Each copy carries its own resolved style inline, read once and replayed. Those values are px, so a container-relative element needs them again when its box changes: a ResizeObserver re-snapshots rather than paying a subtree walk every frame.
  • A beat that moved and faded at once kept a full-strength smear behind a vanishing element. The element's opacity now rides the group; the 1/N weight stays on the copies.

Plus one guard the old stage did not need: a target is blurred once, so a second attachMotionBlur() call naming the same element leaves the first call's copies alone rather than stacking a second set over them and doubling the ink.

Test plan

packages/cli/src/registry/motionBlurShutter.browser.test.ts is rewritten against the clone-group stage. 17 tests, all green.

The shutter claims are still pinned to the fixture (__fixtures__/motion-blur-ae-reference.json): window ends land on the neighbouring frames' measured displacements, the staircase pitch is even on constant velocity, every copy is at the measured 1/16 and none at full opacity.

New assertions for this stage: the group isolates, the group precedes the element so the sharp instance paints over it, the group carries the element's opacity, a scale-only beat and a 3D-rotation-only beat each produce N + 1 distinct copy transforms, every copy in a 3D beat carries the perspective, the deadband hides the group at rest and below half a pixel, shutterAngle: 0 disables it, a second attach is a no-op, and a resize re-reads the styles.

Every new assertion was mutation-tested: dropping the isolation, dropping the opacity mirror, dropping the per-copy perspective, inserting the group after the element, dropping the attach guard and never observing resize each fail the suite. Confirmed non-vacuous rather than assumed.

The snippet now has four inlined copies (the demo plate, the example composition, shutter-slam and its demo plate), which the existing copy test covers. Two changes were needed to keep that honest: the comparison now joins runs of code lines, because oxfmt wraps the same statement differently at each nesting depth and adds a trailing comma when it breaks an object literal, so a copy at indent 10 or 12 can never be byte-equal to the source at indent 4; and every statement in the snippet long enough to wrap is pre-broken, which makes the formatter a fixed point at every indent. Comment lines still compare line by line, so prose drift is caught.

shutter-slam was executed in a browser, not only typechecked: 17 copies with 17 distinct transforms at every one of the six beats, the blur: none path creates no group, and an out-of-range cue is pulled back rather than silently dropped (cues="0,1,2,3,4,5.9" on a 6 s mount schedules the Z turn at 5.267 so it ends on the last frame; with exit: up it moves to 4.932 and the exit runs 5.623 to 6.0).

Size

3480 changed lines against the merge base, over the 1000-line convention, so here is the split rather than a claim it is small: about 1300 lines are hand-written (the snippet, the new component's own script, the test, three manifests) and about 2180 are generated artifacts or test-enforced verbatim copies of the snippet. I did not cut it in two because both available seams are worse than the size: separating the snippet from its inlined copies breaks the copy test in the first commit, and separating the component from its generated catalog pages leaves the docs describing a component that is not there.

Notes, not changes

  • docs/public/catalog/components/shutter-slam.json is 128 KB because the payload generator inlines the GSAP CDN script. Every component payload regenerated today would do the same; the other 218 are smaller only because they were generated before that behaviour existed.
  • scripts/generate-catalog-pages.ts:1252 routes any item tagged 3d to Showcases, which makes the Camera & 3D branch at :1258 unreachable for exactly the tag it names. I found this by tagging motion-blur with 3d and watching it leave the Components nav; I dropped the tag rather than touch the router.
  • node scripts/lint-registry-items.mjs reports 25 pre-existing failures, all blocks/code-*, none of them these two items. They surface only once @hyperframes/lint is rebuilt, so a stale dist hides them.

— Miga

…ase as shutter-slam

The motion-blur snippet integrated translation only. Its output stage was one
SVG filter per target whose copies were feOffset, and feOffset only translates,
so a beat that only scaled or only turned sampled a displacement of zero and
rendered sharp. No SVG filter primitive can give a copy its own affine
transform, so the sampling loop was never the problem: the output stage was.

Copies are now DOM clones of the element in an isolation:isolate group, each at
1/N opacity with mix-blend-mode plus-lighter, each carrying the element's
resolved transform at its sample time. plus-lighter adds premultiplied colour,
so N + 1 copies at 1/N sum to the average the shutter integral asks for; the
isolation is load-bearing rather than tidy, because without a transparent
backdrop of its own the first copy adds onto the page and blows a light
background out to white. Reading the resolved matrix instead of a list of named
GSAP properties is what makes every driver work at once: translation, scale,
rotation, 3D rotation and skew arrive in the same one value.

Measured against the After Effects export the shutter model was fitted to, per
beat, previous stage to this one: scale +0.62 dB, rotate X +0.43, rotate Y
+0.84, rotate Z +1.15, translate X -0.18, translate Y -0.04. The two small
translate regressions are inherent and not tunable: the filter offset one
rasterisation so every copy was pixel-identical, where each copy is now
rasterised independently at its own fractional transform with its own subpixel
antialiasing.

Three consequences of the new stage, each carrying its own fix:

- mix-blend-mode flattens preserve-3d, so a copy cannot inherit the parent's 3D
  context. Each copy carries the parent's perspective as its own first transform
  function instead.
- A copy is styled by nothing that selected the original, since dropping the id
  also drops every #id rule that gave it size, colour and font. Each copy
  carries its own resolved style inline, read once and replayed. Those values
  are px, so a container-relative element needs them again when its box changes:
  a ResizeObserver re-snapshots rather than paying a subtree walk every frame.
- A beat that moved and faded at once kept a full-strength smear behind a
  vanishing element. The element's opacity now rides the group; the 1/N weight
  stays on the copies.

The axis option is gone. It selected which of x and y to sample, a question the
resolved matrix does not have.

shutter-slam is the new component: one word through six single-property beats,
two translate slams, a scale punch, then a full turn about each axis, which
exercises every driver the model covers in one mount. Easing is power2.inOut
because it measured best, not because it was the default. A bezier fitted to the
reference's own bbox centres looks like the better curve and renders worse: a
blurred bbox centre is the shutter window's average rather than the instant, so
on an accelerating segment the fit measures its own blur.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
@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 17, 2026, 4:32 PM

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

@miguel-heygen
miguel-heygen merged commit 82f4926 into main Sep 17, 2026
54 checks passed
@miguel-heygen
miguel-heygen deleted the feat/motion-blur-transform-drivers branch September 17, 2026 20:03

This branch was successfully deployed

1 active deployment
staging - docs — 3d42fdeb Deployed Sep 17, 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.

2 participants