feat(registry): blur every transform driver, and ship the reference case as shutter-slam - #4038
Merged
Merged
Conversation
…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>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
Merged
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.
What changed
motion-blurintegrated translation only. Its output stage was one SVG filter per target whose copies werefeOffset, andfeOffsetonly 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: isolategroup, each at1/Nopacity withmix-blend-mode: plus-lighter, each carrying the element's resolved transform at its sample time.plus-lighteradds 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.toFilterSpaceis deleted. Theaxisoption 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:
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.mdwas 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: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-slamshipspower2.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-modeflattenspreserve-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.#idrule 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: aResizeObserverre-snapshots rather than paying a subtree walk every frame.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.tsis 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: 0disables 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-slamand 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-slamwas executed in a browser, not only typechecked: 17 copies with 17 distinct transforms at every one of the six beats, theblur: nonepath 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; withexit: upit 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.jsonis 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:1252routes any item tagged3dto Showcases, which makes theCamera & 3Dbranch at:1258unreachable for exactly the tag it names. I found this by taggingmotion-blurwith3dand watching it leave the Components nav; I dropped the tag rather than touch the router.node scripts/lint-registry-items.mjsreports 25 pre-existing failures, allblocks/code-*, none of them these two items. They surface only once@hyperframes/lintis rebuilt, so a stale dist hides them.— Miga