Skip to content

feat(registry): gooey split block with metaball goo and directional motion blur - #5123

Merged
miguel-heygen merged 10 commits into
mainfrom
feat/registry-gooey-split
Oct 6, 2026
Merged

miguel-heygen merged 10 commits into
mainfrom
feat/registry-gooey-split

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

What

A new catalog block, gooey-split: a dot splits into a three-dot menu through a gooey (metaball) neck, spins into a swirl mark under directional motion blur, breaks back into three dots, and merges into one. 1920x1080, 60 fps, 4.7 s. Timing, spacing and eases were measured frame by frame off a reference recording.

How

  • Goo: an SVG filter (Gaussian blur, then an alpha threshold that paints the ink colour) on a box sized tight around the dots, on only while they split and merge. The threshold sits at the blurred alpha of a lone dot's own edge (0.392 at σ 28), so a separated dot keeps its exact size, and the neck holds at 140 px dot spacing and breaks at 150 px.
  • Motion blur: the existing motion-blur shutter snippet, inlined verbatim, with a one-frame shutter centred on the frame. Every moving part moves by transform alone, set from a pure function of time through GSAP modifiers, so the shutter's sub-frame seeks sample the real trajectory: the smear follows the direction and speed of the spin, frame-accurate, in preview and render alike.
  • Blob into blade: each dot swells along its path while its blade grows out of it (and back), so the mark resolves from the blobs rather than crossfading in.
  • Shared snippet, new option: an opt-in sharp: 0 hides the crisp frame-time copy while an element moves, leaving only the shutter average (a soft blur end to end). It hides through a marker attribute and one !important rule, never the element's own visibility, so autoAlpha timelines keep working. The default (sharp: 1) writes nothing new to the element. A declarative sharp other than 0 or 1 is refused like any other bad option.
  • Shared snippet, fix: copies used to freeze the element's visibility at attach, so an element that entered with autoAlpha (hidden when blurred) never smeared. The smear group now carries the element's current visibility each frame, like its opacity, and a copy keeps a descendant's visibility only where the descendant sets its own. This is one behaviour change for existing users: such elements now smear.
  • Shared snippet, fix (user report): copies from a sub-composition clip that had ended stayed on screen (a 0-4 s clip's copies at 16.4 s), because an explicit visibility on the copies outlived the ended host's inherited hidden. The smear group now sets a visibility only where the element's differs from its parent's, and copies no longer freeze one, so an ancestor that hides later hides the smear too.
  • The inlined snippet copies in motion-blur and shutter-slam carry the same text, as the copy-equality test requires.

Verification

  • packages/cli/src/registry/motionBlurShutter.browser.test.ts: 60 passed, exit 0, three runs in a row. Ten mutations of the changed snippet lines each turn their own test red.
  • oxfmt and oxlint clean; hyperframes lint on the block: 0 errors, 1 warning (file size, from inlining the snippet, as in shutter-slam).
  • Producer render: 282 frames, identical with 1 and 4 workers (the goo filter toggles hold under mid-timeline seeks).
  • Studio preview vs producer render at 8 instants (split neck, spin, blob into blade, mark, break, merge): mean absolute difference 1.3/255 or less.
  • Producer render of a fixture whose target enters with autoAlpha inside a clip, then moves: before the fix, mid-move, sharp: 1 showed no smear and sharp: 0 showed nothing; after, both smear and still frames are unchanged.
  • The user report's shape (sub-composition clip 0-4 s with blurred elements, another clip 4-20 s), hyperframes snapshot --at 16.4 and a render frame at 16.4 s: stale copies with the snippet on main, none with this branch.
  • skills-manifest.json regenerated with gen:skills-manifest; its --check passes.

The frames below are the render: split, mid-spin blur, blob into blade, the mark, break, merge. Then the Studio preview against the render at the same instants, and the MP4.

Review

Independent adversarial review, one report per head. Final round at this head (4ffc033): no BLOCKERs, no MAJORs, no MINORs.

Head Findings Resolution
dc8cac4 MAJOR: sharp: 0 kept an autoAlpha target hidden; 4 MINOR (default path dropped an inline !important, default path untested, sharp values unchecked, doc) hide by marker attribute, never the element's visibility; default-path test; declarative refusal; docs
1519e7c MAJOR: a target hidden at attach drew nothing while moving (on main: no smear) copies stop freezing visibility; the group carries the current one
60b3761 MINOR: a child hidden by an id rule showed in the smear read a hidden target as shown while copying styles
9452d1d MINOR: restore not in finally; restored priority untested try/finally; test
b1d4bd7 none
4ffc033 none; the user report reproduced on the snippet from main and is fixed here (snapshot at 16.4 s, also with mid-move seeks first)
Audited Trusting Not exercised
Real-Chromium repros of every finding before and after; default path byte-identical to main on the target; mutations per new test; renders with 1 and 4 workers pixel-identical; block pixel-identical across every round The producer fixture's numbers (they agree with the reviewer's direct seeks) Targets inside a shadow root

Render frames: split, spin blur, blob into blade, mark, break, merge

Studio preview (top) against the producer render (bottom) at the same instants

gooey-split.mp4

…otion blur

A dot splits into a three-dot menu through a gooey neck, spins into a
swirl mark under shutter motion blur, breaks back into three dots and
merges into one. Timing, spacing and eases are measured off a reference
recording frame by frame.

The motion-blur snippet gains an opt-in `sharp: 0` option that hides the
crisp frame-time copy while an element moves, leaving only the shutter
average. The default is unchanged; the inlined copies carry the same text.
…isibility

Hiding through the element's own visibility fought timelines that
write it (autoAlpha): a target faded in after attach stayed hidden at
rest. A marker attribute and one !important rule now do the hiding, the
default path writes nothing, and a declarative sharp other than 0 or 1
is refused like any other bad option.
…ttached

Copies froze the target's computed visibility at attach, so a target
entering with autoAlpha never smeared (and vanished entirely under
sharp 0). The group now carries the target's current visibility each
frame, read with the sharp 0 marker lifted, and a copy keeps a
descendant's visibility only where it differs from its parent's.
The smear group set an explicit visibility on every moving frame, and
copies used to freeze an explicit visible, so an ancestor clip hiding
after the last update could not hide them: copies from a 0-4 s
sub-composition stayed on screen at 16.4 s. The group now sets a
visibility only where the element's differs from its parent's.
Also regenerates skills-manifest.json for the motion-blur skill doc.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 6, 2026 19:05
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 2055 (base branch 2055), smooth 1515 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@jrusso1020 jrusso1020 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.

Approving at 4ffc0331. Most of my reading went to the shared motion-blur snippet, since every existing user of motion-blur and shutter-slam gets those changes. The block itself is a self-contained composition.

Shared snippet:

  • sharp: 0 can't leave an element hidden. The marker comes off at the start of every applyShutter pass, before the frame's transform is read, and goes back on only when the element moved. An element at rest is always sharp. discard() only runs when attach throws, which is before any frame has set the marker, so a torn-down target can't keep it either. The option stays opt-in: with the default sharp: 1, installHideRule never runs and the marker is never set.
  • Visibility now follows the element instead of being frozen at attach. The group sits as the element's previous sibling, so leaving visibility empty when the element matches its parent lets an ancestor that hides later, like an ended clip, hide the smear too. That fixes the stale-copies report as a class: no copy and no group ever pins a visibility of its own, so every ancestor hiding path gets it, not just sub-composition clips.
  • snapshot()'s temporary visible !important is undone exactly. It goes back to the inline value and its priority, inside a finally. An inline !important also beats the marker's stylesheet !important, so a sharp: 0 target hidden by the marker is read as shown while its styles are copied.
  • The shadow-root case listed under "not exercised" would need a target that is a direct child of a shadow root, where getComputedStyle(parentNode) would throw. Compositions run inside the player's iframe, and only the iframe container sits in the player's shadow root, so a composition element doesn't hit that case today.
  • Copies: gooey-split, motion-blur and shutter-slam each carry one snippet between the sentinels, and the copy-equality test holds them equal. The docs/catalog and docs/public/catalog copies still show the old snippet. That's expected: check-catalog-source-pr keeps generated catalog output out of source PRs, and catalog-publish regenerates it after merge.

Block: it loads GSAP and CustomEase from jsdelivr like the other blocks, has no Math.random or clock reads, and its only setTimeout is the snippet's own registry poll. registry-item.json matches the existing block shape.

Local: motionBlurShutter.browser.test.ts passed 60/60 on each of two runs. The other registry test files need @hyperframes/core built, which I didn't do. CI covers them and is green at this head: 95 pass and 1 skipped, including every required check.

Non-blocking: visibility is read with getComputedStyle(parentNode) once per target per frame, on top of the per-sample transform reads. That's negligible at the block's three targets, but worth keeping in mind if someone marks dozens of elements.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit b50dead Oct 6, 2026
171 of 172 checks passed
@miguel-heygen
miguel-heygen deleted the feat/registry-gooey-split branch October 6, 2026 21:37
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