diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index 5592820..e6162da 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -224,6 +224,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/stagger-animations.md](rules/stagger-animations.md) - Stagger animations with increasing style.animation.delay - [rules/layer-order.md](rules/layer-order.md) - Layer order matters: first in array = behind, last = front - [rules/card-flex-layout.md](rules/card-flex-layout.md) - Scene = implicit flex container; use card/flex for nested layout +- [rules/terminal-product-register.md](rules/terminal-product-register.md) - Complete visual register for demonstrating a CLI product: palette, the two type scales, the composed terminal pane, beat proportions, and which transition to spend where. Every value measured, with the derivations kept. - [rules/world-view.md](rules/world-view.md) - **CRITICAL:** `world` view = the only mechanism for real continuity across beats (no scene-boundary cuts); `world-position` coordinate model + ambient-halo recipe - [rules/continuous-presets.md](rules/continuous-presets.md) - Continuous presets (pulse, float, shake, spin) need loop: true - [rules/timing-constraints.md](rules/timing-constraints.md) - Timing: start_at must be < end_at, duration > 0 @@ -732,7 +733,7 @@ Config types: `string`, `number`, `boolean`, `object`, `array`. Omitted override { "type": "fade", "duration": 0.5 } ``` -**14 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `corner_reveal`, `none` +**15 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `corner_reveal`, `pixel_dissolve`, `none` `corner_reveal` uncovers the incoming scene through a rectangle anchored at one corner: two edges stay pinned to the frame, the other two travel until it fills. @@ -748,6 +749,21 @@ not pushed, which is what separates it from `slide` and from the full-width `corner` takes `top_right` (default), `top_left`, `bottom_right`, `bottom_left` and is ignored by every other type. +`pixel_dissolve` turns the frame over cell by cell on a square lattice, each +cell **fading** on its own schedule. Mid-transition the frame is a mosaic of +both scenes with a band of half-faded cells between them — which is what +separates it from `dissolve` (one global opacity, no structure) and from the +wipes (a single hard boundary). + +```json +{ "type": "pixel_dissolve", "duration": 0.7, "cell": 48, "seed": 11 } +``` + +| Field | Default | Notes | +| --- | --- | --- | +| `cell` | `48.0` | Cell edge in px. Smaller reads as grain, larger as blocks. | +| `seed` | `11` | Which cells turn first. Same seed → same dissolve, every render. | + Default duration: `0.5` seconds. --- diff --git a/.claude/skills/rustmotion/rules/terminal-product-register.md b/.claude/skills/rustmotion/rules/terminal-product-register.md new file mode 100644 index 0000000..41612c3 --- /dev/null +++ b/.claude/skills/rustmotion/rules/terminal-product-register.md @@ -0,0 +1,302 @@ +# Rule: The Terminal-Product Register + +A visual register for demonstrating a **command-line product**: two alternating +grounds, one statement per beat, a terminal window as the recurring subject, and +a mark that assembles from its own pixels at the end. + +Every number below was measured off a 3840×2160 reference piece rather than +chosen, and the derivations are kept so you can re-derive them for a different +frame size. Values are given as **fractions of the frame width** — that is the +unit that survives a change of resolution; absolute pixel values are shown for +1920×1080 only as a convenience. + +Use this register when the brief is "show what our CLI does". Do not use it for +data-heavy decks (see [chart-types.md](chart-types.md)) or for the statement-only +dark-premium look (see [1600-brutalist-style.md](1600-brutalist-style.md)). + +--- + +## 1. Palette — five colours, no sixth + +| Role | Hex | Where | +| --- | --- | --- | +| Warm ground | `#EEE9E2` | opening beat | +| Cold ground | `#F9F9F7` | statement cards | +| Accent | `#FB652A` | demo beats, the second colour in a headline | +| Terminal | `#101010` | the window's fill | +| Outro | `#0E0F1D` | the closing beat | +| Ink | `#1A1A1A` | all type on a light ground | + +The accent is the **only** saturated colour. It carries the demo grounds, the +second line of every headline, and the mark. A second saturated hue anywhere +breaks the register — if the subject's brand has one, replace the accent +wholesale rather than adding to it. + +--- + +## 2. Grounds — a flat colour plus one texture layer + +A scene `background` is **either** a colour **or** presets, never both. The flat +ground is therefore a two-stop gradient of a single colour, with the texture +layered on top: + +```json +"background": [ + { "preset": "gradient_shift", "speed": 0, + "gradient_shift": { "colors": ["#FB652A", "#FB652A"], "gradient_type": "linear" } }, + { "preset": "pixel_grid", "speed": 1.0, + "pixel_grid": { "colors": ["#FFFFFF33"], "size": 7, "spacing": 14, + "density": 0.32, "density_ramp": "edges", + "radius": 1, "motion": "twinkle" } } +] +``` + +**The demo ground's texture is a vignette, not a uniform scatter.** Measured +across the tenths of the frame, cell density runs + +``` +10.9 · 6.5 · 0.8 · 0.2 · 0.2 · 0.2 · 0.2 · 0.9 · 7.8 · 8.8 % +``` + +— heavy at both edges, effectively empty through the middle 60 %. That is what +keeps the texture off the window that sits in the centre. `density_ramp: "edges"` +does it; `radial` is its exact inverse and will crowd the subject. + +**The light grounds carry a thin line grid**, which no preset draws directly. +Get it by negative: a full-density lattice whose cells are 2 px smaller than the +pitch, in a shade *lighter* than the ground, so the gaps read as lines. + +```json +{ "preset": "pixel_grid", "speed": 1.0, + "pixel_grid": { "colors": ["#FFFFFF"], "size": 428, "spacing": 430, "density": 1.0 } } +``` + +--- + +## 3. Typography — two scales, and one weight you must measure + +**Two distinct scales, not one scaled by role.** + +| Role | Fraction of width | 1920 | Use | +| --- | --- | --- | --- | +| Opening line | 0.036 | 69 px | one line across the frame, over the tiles | +| Statement display | 0.0747 | 143 px | the two-line cards | +| Body / caption | 0.015 | 29 px | the outro caption | + +The statement block spans **66 % of the frame width** and sits **11.4 % in from +the left**. Calibrate the size against the block width, not the other way round: +change the font and the same `font-size` gives a different span. + +**Colour changes per line, not inside one.** The first line takes the accent, the +second the ink: + +``` +One shell command, ← accent +any server type ← ink +``` + +**`line-height: 0.9`** — display type set tight enough that the two lines read as +one shape. **`letter-spacing: -2`.** + +### Weight: measure the stem, not the ink + +Ink coverage inside the block's bounding box is a **misleading** metric: it mixes +weight and proportions, and a narrower face shows less ink at the same weight. It +sent this reconstruction to `300` when the reference was a medium. + +Measure the **stem width relative to the cap height** instead — that isolates +weight: + +| | stem / cap | +| --- | --- | +| reference | 11.0 % | +| `font-weight: 300` | 7.8 % | +| **`font-weight: 400`** | **11.2 %** | +| `font-weight: 500 / 600 / 700` | 15.8 % | + +The plateau above 400 is the tell: the default family ships three faces, so +**400 is its medium.** Asking for 500 gets bold. + +Entrances are `char_fade_in` with `granularity: "word"`, `stagger: 0.05`, +`duration: 0.45`, and 0.18 s between the two lines. Word-by-word is what keeps a +3-second card from reading as a slide. + +--- + +## 4. The terminal window — composed, not the `terminal` component + +**`terminal` cannot express this.** `TerminalLine` carries one `color` for the +whole line, and this register colours *fragments inside* a line — a product name +in the accent inside a grey version string, a flag in cyan inside a sentence. +Build the window from parts and use `rich_text`, whose spans do carry per-span +colour. + +### Geometry, measured + +| | fraction | 1920×1080 | +| --- | --- | --- | +| width | 0.78 of frame width | 1498 px | +| left inset | 0.111 | 213 px | +| top | 0.12 of frame height | 130 px | +| bottom | lands **on** the frame's bottom edge | 1080 px | + +The pane is anchored, not centred, and it bleeds to the bottom. Same 11 % margin +as the type — the register has one margin, used everywhere. + +### Inside the pane + +- **The transcript flows from the top.** Only the mode rule and the status bar + are pinned to the bottom, by a `flex-grow: 1` spacer *between* content and + rule. Pushing the whole body down with `justify-content: flex-end` empties the + top and hangs the text at the bottom — wrong. +- Mono face: **`Menlo`**. `SF Mono` is not exposed under that name by the font + manager and falls back to a proportional face **without a word**, which + destroys the mono grid silently. +- `rich_text` span fields are **kebab-case** (`font-family`, `font-size`) and + unknown keys are accepted and ignored. `font_family` in snake_case silently + leaves every span at the component default. +- Set the size and family on the **component style too**, not only per span: the + row's height comes from the component. + +### The re-centring move + +The pane enters large and bleeding, then un-zooms to the middle as the second +line of output appears: + +```json +{"name": "keyframes", "delay": 1.16, "keyframes": [ + {"property": "scale", "keyframes": [{"time": 0, "value": 1.0}, {"time": 0.55, "value": 0.78}], "easing": "ease_out"}, + {"property": "translate_y", "keyframes": [{"time": 0, "value": 0}, {"time": 0.55, "value": -65}], "easing": "ease_out"} +]} +``` + +**`scale` alone does not re-centre.** Anchored top-left and bleeding past the +bottom, the pane's centre sits below the frame's; scaling in place leaves it +hanging low. The translate is what re-centres, and its value comes from the +geometry: `frame_centre_y − pane_centre_y`. + +### One session, one pane + +When two transcripts follow each other, keep **one** scene and hand over +*inside* the window — a scene change cross-fades the window itself, which a +continuous session never does. Each line carries its own schedule (`at` to +appear, `until` to leave), and the two transcripts are **overlaid**, not stacked: +a faded-out line keeps its box in the flex flow, so appending the second after +the first pushes it down by however many invisible rows the first had. + +--- + +## 5. Rhythm — the demo beat dominates + +The reference gives **41 %** of its runtime to the single session beat. Compress +by keeping that shape, not by trimming every beat evenly: a statement card reads +in 2.4 s, a terminal beat does not. + +| beat | share | +| --- | --- | +| opening claim | 15 % | +| statement | 12 % | +| **session** | **41 %** | +| turn | 10 % | +| second command | 10 % | +| outro | 11 % | + +### The duration arithmetic that bites + +**Every transition overlaps the two scenes it joins**, so the scene durations +must sum to *more* than the target: + +``` +sum(durations) − sum(transition durations) = rendered duration +``` + +Writing 20 s of scenes with five 0.5 s transitions renders 17.5 s. Recompute this +whenever a transition's duration changes — it is the single most common way a +piece silently drifts off its target length. + +--- + +## 6. Transitions — one gesture, used once + +Default to `dissolve` at 0.5 s. Spend the one distinctive transition on the +**exit of the main demo**, where the piece turns: + +```json +{ "type": "pixel_dissolve", "duration": 0.85, "cell": 46, "seed": 11 } +``` + +The frame turns over cell by cell, each cell fading on its own schedule, from the +**border inward** (`edges_in`, the default) so the subject in the centre is the +last thing to go. Mid-transition both scenes are on screen as a mosaic — that is +what separates it from `dissolve` (one global opacity, no structure) and from the +wipes (a single hard boundary). + +Using it on every cut spends it. One occurrence reads as an intention; five read +as a filter. + +`corner_reveal` is the same family — a rectangle anchored at a corner, uncovering +a still scene — and works where a harder, faster turn is wanted. + +--- + +## 7. The outro — the mark builds from its own pixels + +Cells appear one at a time over ~2.3 s, **bottom-up and centre-out**: the base +lands first, the arms close last. Each cell arrives **rotated** and settles +square, fading in rather than popping: + +```json +"animation": [ + {"name": "fade_in", "delay": 0.10, "duration": 0.28}, + {"name": "keyframes", "delay": 0.10, "keyframes": [ + {"property": "rotation", "keyframes": [{"time": 0, "value": -14}, {"time": 0.42, "value": 0}], "easing": "ease_out"}, + {"property": "scale", "keyframes": [{"time": 0, "value": 0.55}, {"time": 0.42, "value": 1}], "easing": "ease_out"} + ]} +] +``` + +Use a **fixed tilt table**, not a random one: two renders of one scenario must be +identical, and a seeded RNG in the generator is one more thing to reproduce. + +Stagger 0.075 s per cell. Colour the cells by **row**, in a top-to-bottom ramp — +the band structure is what makes a pixel mark read as a mark rather than as +confetti. + +--- + +## 8. Applying this to another subject + +What is **register** (keep): + +- two alternating grounds, one accent, ink on light +- the two type scales and the per-line colour switch +- the anchored, bleeding pane and its re-centring move +- the demo beat taking ~40 % of the runtime +- one distinctive transition, at the turn +- a mark assembling from its own cells + +What is **subject** (replace): + +- the accent hex, and the outro's dark ground if the brand has one +- the tiles in the opening beat — they name the ecosystem the product plugs into +- every string in the transcript, and the mark's cell layout +- the mono content: keep it plausible and short enough that a line never wraps + +What to **re-measure** for a new frame size: nothing, if you keep the fractions. +Everything, if you take the 1920 pixel values literally. + +--- + +## Traps, collected + +| Symptom | Cause | +| --- | --- | +| Type looks bold at any weight | Asking for 500; the family's medium is 400 | +| Mono text is proportional | Family `SF Mono` — not exposed; use `Menlo` | +| Every span at default size/font | Span fields are kebab-case; snake_case is ignored silently | +| Second transcript starts low | Faded-out lines keep their box; overlay instead of stacking | +| Pane shrinks but hangs low | `scale` without the compensating translate | +| Texture crowds the subject | `density_ramp: "radial"` instead of `"edges"` | +| Video is shorter than intended | Transition overlap not added back to the scene sum | +| Background rejected | A scene background is a colour **or** presets, never both | +| Left inset ignored on a scene | `Scene.layout.padding` is a single uniform value; use a container | diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 215d4b0..77f912d 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -68,6 +68,11 @@ fn apply_text_anim_preset( cursor_x: f32, line_y: f32, unit_width: f32, + // The tracking the *cursor* was advanced with. Drawing at 0 while the + // advance carries a negative value makes the glyphs overrun their slot and + // swallow the inter-word space — visible only once the overrun approaches + // a space's width, i.e. at small sizes or long words. + letter_spacing: f32, preset: &CharAnimPreset, t: f32, time: f64, @@ -99,12 +104,30 @@ fn apply_text_anim_preset( canvas.translate((center_x, center_y)); canvas.scale((scale, scale)); canvas.translate((-center_x, -center_y)); - draw_text_with_fallback(canvas, text, font, emoji_font, 0.0, cursor_x, line_y, paint); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + paint, + ); } CharAnimPreset::FadeIn => { let mut p = paint.clone(); p.set_alpha_f(t * paint.alpha_f()); - draw_text_with_fallback(canvas, text, font, emoji_font, 0.0, cursor_x, line_y, &p); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + &p, + ); } CharAnimPreset::Wave => { let wave_offset = @@ -116,7 +139,7 @@ fn apply_text_anim_preset( text, font, emoji_font, - 0.0, + letter_spacing, cursor_x, line_y + wave_offset, &p, @@ -133,7 +156,16 @@ fn apply_text_anim_preset( canvas.translate((center_x, center_y)); canvas.scale((scale, scale)); canvas.translate((-center_x, -center_y)); - draw_text_with_fallback(canvas, text, font, emoji_font, 0.0, cursor_x, line_y, paint); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + paint, + ); } CharAnimPreset::RotateIn => { let angle = (1.0 - t) * -90.0; @@ -142,7 +174,16 @@ fn apply_text_anim_preset( canvas.translate((center_x, center_y)); canvas.rotate(angle, None); canvas.translate((-center_x, -center_y)); - draw_text_with_fallback(canvas, text, font, emoji_font, 0.0, cursor_x, line_y, &p); + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + letter_spacing, + cursor_x, + line_y, + &p, + ); } CharAnimPreset::SlideUp => { let offset_y = (1.0 - t) * font_size * 0.8; @@ -153,7 +194,7 @@ fn apply_text_anim_preset( text, font, emoji_font, - 0.0, + letter_spacing, cursor_x, line_y + offset_y, &p, @@ -183,7 +224,7 @@ fn apply_text_anim_preset( text, font, emoji_font, - 0.0, + letter_spacing, cursor_x, line_y + offset_y, &p, @@ -291,6 +332,7 @@ fn render_char_animation( cursor_x, line_y, word_width, + letter_spacing, &char_anim.preset, t, time, @@ -334,6 +376,11 @@ fn render_char_animation( cursor_x, line_y, ch_width, + // Single characters carry no internal tracking, so this is + // 0 by construction — passed explicitly rather than left + // to a default, since the word path above needs the real + // value and the two must not drift apart. + 0.0, &char_anim.preset, t, time, diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 944552c..913907b 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,5 +1,7 @@ use crate::engine::animator::ease; -use crate::schema::{EasingType, PanBackground, TransitionCorner, TransitionType}; +use crate::schema::{ + EasingType, PanBackground, PixelDissolveOrder, TransitionCorner, TransitionType, +}; use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, Path, Rect}; /// Composite two RGBA frames during a transition. @@ -12,6 +14,9 @@ pub fn apply_transition( progress: f64, transition_type: &TransitionType, corner: TransitionCorner, + cell: f32, + seed: u32, + order: PixelDissolveOrder, ) -> Vec { let progress = progress.clamp(0.0, 1.0) as f32; @@ -39,6 +44,9 @@ pub fn apply_transition( TransitionType::CornerReveal => { corner_reveal(frame_a, frame_b, width, height, progress, corner) } + TransitionType::PixelDissolve => { + pixel_dissolve(frame_a, frame_b, width, height, progress, cell, seed, order) + } TransitionType::CameraPan => blend_fade(frame_a, frame_b, progress), TransitionType::None => { if progress < 0.5 { @@ -105,6 +113,130 @@ fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_ } } +/// How much a cell's own position pulls its threshold, against the hash. Enough +/// to read as a front travelling inward, little enough that the front stays +/// ragged instead of collapsing to a clean rectangle closing in. +const SPATIAL_WEIGHT: f32 = 0.72; + +/// Deterministic 0..1 threshold for a cell — the moment it starts to turn. +/// +/// A hash of the cell's coordinates, not a random draw: the transition must +/// dissolve the same way on every render, and re-rolling per frame would make +/// the mosaic boil instead of resolve. +fn cell_hash01(col: i32, row: i32, seed: u32) -> f32 { + let mut h = seed + .wrapping_mul(0x9E37_79B9) + .wrapping_add((col as u32).wrapping_mul(0x85EB_CA6B)) + .wrapping_add((row as u32).wrapping_mul(0xC2B2_AE35)); + h ^= h >> 16; + h = h.wrapping_mul(0x7FEB_352D); + h ^= h >> 15; + (h & 0x00FF_FFFF) as f32 / 0x0100_0000 as f32 +} + +/// The threshold once the spatial order is folded in. +/// +/// `EdgesIn` gives border cells an early threshold and the centre a late one, +/// so the subject in the middle is the last thing to go. The hash still +/// contributes: without it the front is a rectangle closing in, which reads as +/// a wipe rather than a dissolve. +fn cell_threshold( + col: i32, + row: i32, + cols: i32, + rows: i32, + seed: u32, + order: PixelDissolveOrder, +) -> f32 { + let noise = cell_hash01(col, row, seed); + if order == PixelDissolveOrder::Random { + return noise; + } + // Chebyshev distance from the centre, 0 at the middle and 1 at the border: + // it follows the frame's own rectangle, where a Euclidean radius would + // leave the corners lagging behind the edges. + let (cx, cy) = ((cols - 1) as f32 / 2.0, (rows - 1) as f32 / 2.0); + let dx = if cx > 0.0 { + (col as f32 - cx).abs() / cx + } else { + 0.0 + }; + let dy = if cy > 0.0 { + (row as f32 - cy).abs() / cy + } else { + 0.0 + }; + let edge = dx.max(dy).clamp(0.0, 1.0); + let spatial = match order { + PixelDissolveOrder::EdgesIn => 1.0 - edge, + PixelDissolveOrder::CenterOut => edge, + PixelDissolveOrder::Random => unreachable!("handled above"), + }; + (spatial * SPATIAL_WEIGHT + noise * (1.0 - SPATIAL_WEIGHT)).clamp(0.0, 1.0) +} + +/// Cross-fade the two frames cell by cell on a square lattice. +/// +/// Each cell has its own start time, so at any instant the frame is a mosaic of +/// both scenes with a band of half-faded cells between them — which is what +/// separates this from `dissolve` (one global opacity, no structure) and from +/// the wipes (a single hard boundary). `feather` is what makes a cell *fade* +/// rather than flip: with it at 0 the effect degrades to a hard checkerboard. +fn pixel_dissolve( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + cell: f32, + seed: u32, + order: PixelDissolveOrder, +) -> Vec { + let mut surface = match create_skia_surface(width, height) { + Some(s) => s, + None => return blend_fade(frame_a, frame_b, progress), + }; + let img_a = match frame_to_image(frame_a, width, height) { + Some(i) => i, + None => return blend_fade(frame_a, frame_b, progress), + }; + let img_b = match frame_to_image(frame_b, width, height) { + Some(i) => i, + None => return blend_fade(frame_a, frame_b, progress), + }; + + let cell = cell.max(1.0); + let cols = (width as f32 / cell).ceil() as i32; + let rows = (height as f32 / cell).ceil() as i32; + + let canvas = surface.canvas(); + canvas.draw_image(&img_a, (0.0, 0.0), None); + + // The whole run has to finish by progress 1, so the schedule is compressed + // to leave room for the last cell's own fade. + const FEATHER: f32 = 0.35; + let p = progress.clamp(0.0, 1.0) * (1.0 + FEATHER); + + for row in 0..rows { + for col in 0..cols { + let t = cell_threshold(col, row, cols, rows, seed, order); + let alpha = ((p - t) / FEATHER).clamp(0.0, 1.0); + if alpha <= 0.001 { + continue; + } + let rect = Rect::from_xywh(col as f32 * cell, row as f32 * cell, cell, cell); + canvas.save(); + canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false); + let mut paint = Paint::default(); + paint.set_alpha_f(alpha); + canvas.draw_image(&img_b, (0.0, 0.0), Some(&paint)); + canvas.restore(); + } + } + + surface_to_pixels(surface, width, height) +} + fn blend_fade(frame_a: &[u8], frame_b: &[u8], progress: f32) -> Vec { let inv = 1.0 - progress; frame_a @@ -846,3 +978,150 @@ mod corner_reveal_tests { } } } + +#[cfg(test)] +mod pixel_dissolve_tests { + use super::*; + + /// `edges_in` must turn the border before the middle — that is the whole + /// point: whatever sits in the centre is the last thing to go. + #[test] + fn edges_in_turns_the_border_first() { + let (cols, rows) = (40, 24); + let border: Vec = (0..cols) + .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn)) + .collect(); + let middle: Vec = (0..cols) + .map(|c| cell_threshold(c, rows / 2, cols, rows, 11, PixelDissolveOrder::EdgesIn)) + .collect(); + let avg = |v: &Vec| v.iter().sum::() / v.len() as f32; + assert!( + avg(&border) < avg(&middle) - 0.15, + "border {:.2} must clearly precede the middle {:.2}", + avg(&border), + avg(&middle) + ); + // The very centre goes last. + let centre = cell_threshold( + cols / 2, + rows / 2, + cols, + rows, + 11, + PixelDissolveOrder::EdgesIn, + ); + assert!(centre > 0.6, "the centre cell must be late, got {centre}"); + } + + /// …and `center_out` is its mirror, or the option is decoration. + #[test] + fn center_out_is_the_mirror_of_edges_in() { + let (cols, rows) = (40, 24); + for (c, r) in [(0, 0), (20, 12), (39, 5)] { + let a = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::EdgesIn); + let b = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::CenterOut); + // Same hash contribution, opposite spatial term. + assert!( + (a + b - (SPATIAL_WEIGHT + 2.0 * (1.0 - SPATIAL_WEIGHT) * cell_hash01(c, r, 11))) + .abs() + < 1e-5 + ); + } + } + + /// The front has to stay ragged. A purely spatial threshold would close a + /// clean rectangle inward, which reads as a wipe, not a dissolve — so + /// neighbours at the same distance from the centre must still differ. + #[test] + fn the_front_is_ragged_not_a_closing_rectangle() { + let (cols, rows) = (40, 24); + let top: Vec = (0..cols) + .map(|c| cell_threshold(c, 0, cols, rows, 11, PixelDissolveOrder::EdgesIn)) + .collect(); + let spread = top.iter().cloned().fold(f32::MIN, f32::max) + - top.iter().cloned().fold(f32::MAX, f32::min); + assert!( + spread > 0.15, + "the border turns as one block: spread {spread}" + ); + } + + /// `random` keeps its old behaviour — the spatial term must not leak in. + #[test] + fn random_ignores_position() { + let t = cell_threshold(7, 3, 40, 24, 11, PixelDissolveOrder::Random); + assert_eq!(t, cell_hash01(7, 3, 11)); + } + + /// The same cell must turn at the same moment on every render: a per-frame + /// draw would make the mosaic boil instead of resolve. + #[test] + fn a_cell_keeps_its_threshold() { + assert_eq!(cell_hash01(4, 9, 11), cell_hash01(4, 9, 11)); + let t = cell_hash01(4, 9, 11); + assert!( + (0.0..1.0).contains(&t), + "threshold must be a fraction, got {t}" + ); + } + + /// …and two seeds must dissolve in a different order, or `seed` is a lie. + #[test] + fn the_seed_changes_the_order() { + let a: Vec = (0..40).map(|i| cell_hash01(i, 0, 11)).collect(); + let b: Vec = (0..40).map(|i| cell_hash01(i, 0, 12)).collect(); + assert_ne!(a, b); + } + + /// Neighbours must not turn in step — a threshold that tracks the + /// coordinate sweeps a diagonal line, which is a wipe, not a dissolve. + #[test] + fn neighbouring_cells_turn_at_unrelated_times() { + let close = (0..30) + .flat_map(|c| (0..30).map(move |r| (c, r))) + .filter(|&(c, r)| (cell_hash01(c, r, 11) - cell_hash01(c + 1, r, 11)).abs() < 0.05) + .count(); + // 900 pairs; a swept threshold would put nearly all of them under 0.05. + assert!( + close < 200, + "{close}/900 neighbours turn together — that is a wipe" + ); + } + + /// The spread is the whole point: at half-way the frame must hold cells in + /// *both* states plus some mid-fade, not one global opacity. + #[test] + fn midway_the_frame_holds_both_scenes_and_a_fading_band() { + const FEATHER: f32 = 0.35; + let p = 0.5 * (1.0 + FEATHER); + let alphas: Vec = (0..40) + .flat_map(|c| (0..40).map(move |r| cell_hash01(c, r, 11))) + .map(|t| ((p - t) / FEATHER).clamp(0.0, 1.0)) + .collect(); + let done = alphas.iter().filter(|&&a| a >= 0.999).count(); + let waiting = alphas.iter().filter(|&&a| a <= 0.001).count(); + let fading = alphas.iter().filter(|&&a| a > 0.001 && a < 0.999).count(); + assert!(done > 100 && waiting > 100, "both states must be present"); + assert!( + fading > 50, + "cells must fade, not flip: only {fading} mid-transition" + ); + } + + /// Every cell must be settled by the end, or the last of the outgoing scene + /// survives into the next one. + #[test] + fn every_cell_completes_by_the_end() { + const FEATHER: f32 = 0.35; + let p = 1.0 * (1.0 + FEATHER); + for c in 0..60 { + for r in 0..60 { + let a = ((p - cell_hash01(c, r, 11)) / FEATHER).clamp(0.0, 1.0); + assert!( + a >= 0.999, + "cell ({c},{r}) still at {a} when the transition ends" + ); + } + } + } +} diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 2cb6256..9d36f06 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -593,6 +593,21 @@ pub struct SceneLayout { pub padding: Option, } +/// The order `pixel_dissolve` turns its cells in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PixelDissolveOrder { + /// From the frame's border inward, so the centre — where the subject + /// usually is — is the last thing to go. This is what the reference piece + /// does, and it is the default for that reason. + #[default] + EdgesIn, + /// The mirror: the centre opens first and the border closes last. + CenterOut, + /// No spatial order at all — every cell on its own draw. + Random, +} + /// The corner a `corner_reveal` is anchored to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -614,6 +629,16 @@ pub struct Transition { /// Which corner a `corner_reveal` grows from. Ignored by every other type. #[serde(default)] pub corner: TransitionCorner, + /// Cell edge in px for `pixel_dissolve`. Ignored by every other type. + #[serde(default = "default_transition_cell")] + pub cell: f32, + /// `pixel_dissolve` only: stable scatter selector. Two transitions with the + /// same seed dissolve in the same order. + #[serde(default = "default_transition_seed")] + pub seed: u32, + /// `pixel_dissolve` only: which cells turn first. + #[serde(default)] + pub order: PixelDissolveOrder, #[serde(default = "default_transition_duration")] pub duration: f64, #[serde(default = "default_transition_easing")] @@ -658,10 +683,19 @@ pub enum TransitionType { Slide, Dissolve, CornerReveal, + PixelDissolve, CameraPan, None, } +fn default_transition_cell() -> f32 { + 48.0 +} + +fn default_transition_seed() -> u32 { + 11 +} + fn default_transition_duration() -> f64 { 0.5 } diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 4cac2d7..7075b75 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -1,8 +1,8 @@ use crate::engine::transition::{apply_transition, camera_pan_transition}; use crate::error::{Result, RustmotionError}; use crate::schema::{ - EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionCorner, - TransitionType, VideoConfig, ViewType, + EasingType, PixelDissolveOrder, ResolvedScenario as Scenario, ResolvedView, Scene, + TransitionCorner, TransitionType, VideoConfig, ViewType, }; /// Description of what to render for a specific frame @@ -42,6 +42,10 @@ pub enum FrameTask { transition_type: TransitionType, /// Which corner a `corner_reveal` grows from; inert for every other type. corner: TransitionCorner, + /// `pixel_dissolve` only: cell edge and scatter seed. + cell: f32, + seed: u32, + order: PixelDissolveOrder, transition_duration: f64, easing: EasingType, }, @@ -73,6 +77,10 @@ pub enum FrameTask { transition_type: TransitionType, /// Which corner a `corner_reveal` grows from; inert for every other type. corner: TransitionCorner, + /// `pixel_dissolve` only: cell edge and scatter seed. + cell: f32, + seed: u32, + order: PixelDissolveOrder, transition_duration: f64, easing: EasingType, }, @@ -166,6 +174,9 @@ pub fn render_frame_task_scaled( scene_b_total_frames, transition_type, corner, + cell, + seed, + order, transition_duration, easing, } => { @@ -300,6 +311,9 @@ pub fn render_frame_task_scaled( progress, transition_type, *corner, + *cell, + *seed, + *order, ); apply_post_effects( &mut composited, @@ -355,6 +369,9 @@ pub fn render_frame_task_scaled( frame_in_transition, transition_type, corner, + cell, + seed, + order, transition_duration, easing: _, } => { @@ -385,6 +402,9 @@ pub fn render_frame_task_scaled( progress, transition_type, *corner, + *cell, + *seed, + *order, ); // By symmetry with `SlideTransition` (which applies scene_b's // effects to the blended result, "the transition is the entry @@ -557,6 +577,9 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { frame_in_transition: f, transition_type: transition.transition_type.clone(), corner: transition.corner, + cell: transition.cell, + seed: transition.seed, + order: transition.order, transition_duration: transition.duration, easing: transition.easing.clone(), }); @@ -686,6 +709,9 @@ fn build_slide_view_tasks( scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), corner: transition.corner, + cell: transition.cell, + seed: transition.seed, + order: transition.order, transition_duration: outgoing_effective_duration, easing: easing.clone(), }); @@ -878,6 +904,9 @@ pub(super) fn build_slot_frame_tasks( frame_in_transition: f, transition_type: transition.transition_type.clone(), corner: transition.corner, + cell: transition.cell, + seed: transition.seed, + order: transition.order, transition_duration: transition.duration, easing: transition.easing.clone(), }); @@ -948,6 +977,9 @@ pub(super) fn build_scene_frame_tasks_in_view( scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), corner: transition.corner, + cell: transition.cell, + seed: transition.seed, + order: transition.order, transition_duration: outgoing_effective_duration, easing: easing.clone(), });