feat(animation): chromatic_aberration, a red/cyan fringe on any node - #354
Merged
Merged
Conversation
LeadcodeDev
force-pushed
the
feat/chromatic-aberration-effect
branch
from
September 26, 2026 23:24
0520670 to
f581931
Compare
chromatic_wipe already splits red/cyan channels, but only as a scene transition composited on two finished frame-buffers. Issue #344 point 4 asks for the same fringe on a single arriving element (an icon card glitching into place), which a transition can't express since nothing in it survives past the cut. Add `chromatic_aberration` as a node-level animation effect: `amount` sets the peak channel separation in px, reached the instant the effect starts, decaying to exactly zero by the end of `duration` — mirroring chromatic_wipe's own "zero at the edges" guarantee so no element is left permanently fringed. Unlike the transition's symmetric tent curve (zero at both ends, since it has to hand off cleanly to the next scene), this is an arrival effect: max at the start, settled by the end. Implementation composes two `ImageFilter`s on the node's own paint layer instead of hand-rolling pixel buffers like the transition does: each is the node's content shifted a few px and colour-matrixed down to just its red or cyan channels, then summed with `BlendMode::Plus`. Where both copies overlap the sum reconstructs the original colour exactly; at the edges, where a shifted sample lands outside the node's own painted content, only one channel survives and the fringe shows. This reuses the exact machinery `style.filter` (blur, drop-shadow...) already has for composing an image filter onto a node's save_layer, including its bleed-expansion of the layer bounds, rather than adding a second parallel path. Necessary edits outside the originally scoped file set: `AnimationEffect` (schema/video.rs) is the single tag="name" enum every `style.animation` entry parses through, so the new variant has to live there for the JSON shape to deserialize at all — `ChromaticAberrationConfig` itself lives in schema/animation.rs as scoped. That in turn made the exhaustive match in validate_schema.rs's `entrance_budget` non-exhaustive; giving it its own arm was the right call anyway, not just a compile fix — it's exactly the kind of one-shot effect that check exists for, and it now catches a scene too short to let the fringe fully decay before the cut.
LeadcodeDev
force-pushed
the
feat/chromatic-aberration-effect
branch
from
September 26, 2026 23:31
f581931 to
ba30be7
Compare
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.
Refs #344 — its point 4.
chromatic_wipesplits the red and cyan channels, but only as a scene transition. A reference video shows an icon card arriving with channel separation — a per-element effect.{ "style": { "animation": [{ "name": "chromatic_aberration", "amount": 6, "duration": 0.6 }] } }amount(px, default 6) is the peak separation, reached atdelay, decaying viaeasing(defaultease_out) to exactly 0 bydelay + duration— verified pixel-identical to no effect at all at that point, so no node is left permanently fringed.How it is drawn
It builds a single Skia
ImageFilter: the node's own layer, shifted and colour-matrixed to isolate red one way and cyan the other, summed withBlendMode::Plus. That filter is composed with any existingstyle.filterand fed into the samesave_layerthat already handles opacity and filters — so it inherits that layer's bleed-bounds logic (extended by the current shift) for free. No new layer, no manual pixel buffers.active_chromatic_aberrationmirrorsactive_shimmer's existing read-off-css.animationpattern.A deliberate departure from
chromatic_wipeThe isolated channels are paired as {R} vs {G+B}, not
chromatic_wipe's{R}vs{B}. A naive red-vs-blue split gives red and yellow edges; pairing green with blue gives red and cyan, which is what the issue's own wording asks for. Left edge reads pure red, right edge pure cyan.The curve differs too, and on purpose:
chromatic_wipeis a symmetric tent, zero at both ends, because a transition must not leave a fringe bleeding into the next scene. This is an arrival — maximum at the start, zero at the end.Two edits outside the brief, both forced
Flagging these rather than burying them:
schema/video.rs—AnimationEffect, the#[serde(tag = "name")]enum everystyle.animationentry parses through, lives here and not inschema/animation.rs. The JSON in the issue cannot deserialize without a variant here. One import, one variant, oneshift_delayarm, same shape as the neighbouringShimmer/Glow.cli/commands/validate_schema.rs— the new variant madeentrance_budget's exhaustive match anE0004. The fix is not merely a compile fix, it is the right semantic: a scene shorter thandelay + durationnow errors with "animation finishes at 0.60s ... but scene_duration is 0.30s — it will be truncated", which extends the "nothing left fringed" guarantee to validation time. Verified with a realrustmotion validaterun.Verification
9 tests: 3 serde (including
deny_unknown_fieldsand the exact JSON from the issue), 3 on the shift curve, 3 pixel-level inpaint_order_tests.Proven to catch the absence — forcing the filter to
None:cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(1534) all clean.Written comment-free, per the codebase-wide rule from #345.