diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index bec9ef6..5592820 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -732,7 +732,21 @@ Config types: `string`, `number`, `boolean`, `object`, `array`. Omitted override { "type": "fade", "duration": 0.5 } ``` -**13 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `none` +**14 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `corner_reveal`, `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. +The incoming scene sits still behind the growing window — it is *uncovered*, +not pushed, which is what separates it from `slide` and from the full-width +`wipe_*` band. + +```json +{ "type": "corner_reveal", "duration": 0.5, "corner": "top_right", + "easing": "ease_in_out" } +``` + +`corner` takes `top_right` (default), `top_left`, `bottom_right`, `bottom_left` +and is ignored by every other type. Default duration: `0.5` seconds. diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index faa4ca7..944552c 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,5 +1,5 @@ use crate::engine::animator::ease; -use crate::schema::{EasingType, PanBackground, TransitionType}; +use crate::schema::{EasingType, PanBackground, TransitionCorner, TransitionType}; use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, Path, Rect}; /// Composite two RGBA frames during a transition. @@ -11,6 +11,7 @@ pub fn apply_transition( height: u32, progress: f64, transition_type: &TransitionType, + corner: TransitionCorner, ) -> Vec { let progress = progress.clamp(0.0, 1.0) as f32; @@ -35,6 +36,9 @@ pub fn apply_transition( TransitionType::Iris => iris_transition(frame_a, frame_b, width, height, progress), TransitionType::Slide => slide_transition(frame_a, frame_b, width, height, progress), TransitionType::Dissolve => dissolve_transition(frame_a, frame_b, width, height, progress), + TransitionType::CornerReveal => { + corner_reveal(frame_a, frame_b, width, height, progress, corner) + } TransitionType::CameraPan => blend_fade(frame_a, frame_b, progress), TransitionType::None => { if progress < 0.5 { @@ -46,6 +50,61 @@ pub fn apply_transition( } } +/// Reveal the incoming frame through a rectangle anchored at one corner. +/// +/// Measured on a reference piece, over 15 frames (0.5 s): the right and top +/// edges stay pinned to the frame while the left edge travels 2160 -> 0 and the +/// bottom edge 1480 -> 2152. So it is not a wipe — `wipe_*` moves one +/// full-width band — and not an `iris`, which is a circle. Both edges move at +/// once, and the incoming scene sits still behind the growing window rather +/// than sliding in: what arrives is *uncovered*, not pushed. +fn corner_reveal( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + corner: TransitionCorner, +) -> 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 (w, h) = (width as f32, height as f32); + let rect = corner_rect(corner, w, h, progress); + + let canvas = surface.canvas(); + canvas.draw_image(&img_a, (0.0, 0.0), None); + canvas.save(); + canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false); + canvas.draw_image(&img_b, (0.0, 0.0), None); + canvas.restore(); + + surface_to_pixels(surface, width, height) +} + +/// The revealed rectangle at `progress`, anchored so that two edges stay on the +/// frame and two travel. +fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_safe::Rect { + let p = progress.clamp(0.0, 1.0); + let (rw, rh) = (w * p, h * p); + match corner { + TransitionCorner::TopRight => skia_safe::Rect::from_xywh(w - rw, 0.0, rw, rh), + TransitionCorner::TopLeft => skia_safe::Rect::from_xywh(0.0, 0.0, rw, rh), + TransitionCorner::BottomRight => skia_safe::Rect::from_xywh(w - rw, h - rh, rw, rh), + TransitionCorner::BottomLeft => skia_safe::Rect::from_xywh(0.0, h - rh, rw, rh), + } +} + fn blend_fade(frame_a: &[u8], frame_b: &[u8], progress: f32) -> Vec { let inv = 1.0 - progress; frame_a @@ -717,3 +776,73 @@ mod camera_pan_tests { ); } } + +#[cfg(test)] +mod corner_reveal_tests { + use super::*; + + /// Two edges stay on the frame, two travel. Measured on the reference + /// piece: the right and top edges never move while the left runs + /// 2160 -> 0 and the bottom 1480 -> 2152, over 15 frames. + #[test] + fn the_anchored_edges_never_move() { + for p in [0.05, 0.3, 0.5, 0.8, 1.0] { + let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p); + assert!((r.right - 1920.0).abs() < 1e-3, "right edge moved at {p}"); + assert!(r.top.abs() < 1e-3, "top edge moved at {p}"); + } + } + + /// …and the travelling edges do move, monotonically, in the direction the + /// corner names. + #[test] + fn the_travelling_edges_open_from_the_corner() { + let at = |p| corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p); + let (a, b, c) = (at(0.2), at(0.5), at(0.9)); + assert!( + a.left > b.left && b.left > c.left, + "left edge must travel left" + ); + assert!( + a.bottom < b.bottom && b.bottom < c.bottom, + "bottom must travel down" + ); + } + + /// The ends are the whole point: nothing revealed at 0, everything at 1. + #[test] + fn it_starts_empty_and_ends_full() { + let empty = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 0.0); + assert_eq!((empty.width(), empty.height()), (0.0, 0.0)); + let full = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 1.0); + assert_eq!( + (full.left, full.top, full.right, full.bottom), + (0.0, 0.0, 1920.0, 1080.0) + ); + } + + /// Each corner anchors its own two edges — otherwise `corner` is decoration. + #[test] + fn every_corner_anchors_its_own_edges() { + let (w, h, p) = (1920.0f32, 1080.0f32, 0.4); + let tl = corner_rect(TransitionCorner::TopLeft, w, h, p); + assert!(tl.left.abs() < 1e-3 && tl.top.abs() < 1e-3); + let br = corner_rect(TransitionCorner::BottomRight, w, h, p); + assert!((br.right - w).abs() < 1e-3 && (br.bottom - h).abs() < 1e-3); + let bl = corner_rect(TransitionCorner::BottomLeft, w, h, p); + assert!(bl.left.abs() < 1e-3 && (bl.bottom - h).abs() < 1e-3); + } + + /// Progress outside 0..1 must clamp, not invert the rectangle: a negative + /// width would make the clip empty and the transition would look like a cut. + #[test] + fn out_of_range_progress_clamps() { + for p in [-0.5, 1.5] { + let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p); + assert!( + r.width() >= 0.0 && r.height() >= 0.0, + "inverted rect at {p}" + ); + } + } +} diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 357fc5e..2cb6256 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -593,11 +593,27 @@ pub struct SceneLayout { pub padding: Option, } +/// The corner a `corner_reveal` is anchored to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TransitionCorner { + /// Measured default: the reference piece grows its reveal from here, with + /// the right and top edges pinned and the left and bottom edges travelling. + #[default] + TopRight, + TopLeft, + BottomRight, + BottomLeft, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Transition { #[serde(rename = "type")] pub transition_type: TransitionType, + /// Which corner a `corner_reveal` grows from. Ignored by every other type. + #[serde(default)] + pub corner: TransitionCorner, #[serde(default = "default_transition_duration")] pub duration: f64, #[serde(default = "default_transition_easing")] @@ -641,6 +657,7 @@ pub enum TransitionType { Iris, Slide, Dissolve, + CornerReveal, CameraPan, None, } diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index ac25f56..4cac2d7 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, TransitionType, VideoConfig, - ViewType, + EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionCorner, + TransitionType, VideoConfig, ViewType, }; /// Description of what to render for a specific frame @@ -40,6 +40,8 @@ pub enum FrameTask { scene_a_total_frames: u32, scene_b_total_frames: u32, transition_type: TransitionType, + /// Which corner a `corner_reveal` grows from; inert for every other type. + corner: TransitionCorner, transition_duration: f64, easing: EasingType, }, @@ -69,6 +71,8 @@ pub enum FrameTask { view_b_idx: usize, frame_in_transition: u32, transition_type: TransitionType, + /// Which corner a `corner_reveal` grows from; inert for every other type. + corner: TransitionCorner, transition_duration: f64, easing: EasingType, }, @@ -161,6 +165,7 @@ pub fn render_frame_task_scaled( scene_a_total_frames, scene_b_total_frames, transition_type, + corner, transition_duration, easing, } => { @@ -294,6 +299,7 @@ pub fn render_frame_task_scaled( scaled_h, progress, transition_type, + *corner, ); apply_post_effects( &mut composited, @@ -348,6 +354,7 @@ pub fn render_frame_task_scaled( view_b_idx, frame_in_transition, transition_type, + corner, transition_duration, easing: _, } => { @@ -377,6 +384,7 @@ pub fn render_frame_task_scaled( scaled_h, progress, transition_type, + *corner, ); // By symmetry with `SlideTransition` (which applies scene_b's // effects to the blended result, "the transition is the entry @@ -548,6 +556,7 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { view_b_idx: view_idx, frame_in_transition: f, transition_type: transition.transition_type.clone(), + corner: transition.corner, transition_duration: transition.duration, easing: transition.easing.clone(), }); @@ -676,6 +685,7 @@ fn build_slide_view_tasks( scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), + corner: transition.corner, transition_duration: outgoing_effective_duration, easing: easing.clone(), }); @@ -867,6 +877,7 @@ pub(super) fn build_slot_frame_tasks( view_b_idx: *view_idx, frame_in_transition: f, transition_type: transition.transition_type.clone(), + corner: transition.corner, transition_duration: transition.duration, easing: transition.easing.clone(), }); @@ -936,6 +947,7 @@ pub(super) fn build_scene_frame_tasks_in_view( scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), + corner: transition.corner, transition_duration: outgoing_effective_duration, easing: easing.clone(), }); diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index c42ee84..c5d0ae2 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -498,9 +498,12 @@ fn draw_bg_pixel_grid( let paint = &mut paints[idx]; if cfg.motion == PixelGridMotion::Twinkle { - // Each cell on its own phase, or they blink in unison. + // Full 0 → 1 → 0, not a partial dip: a cell that only fades to + // 10 % still reads as a permanent dot, so the lattice looks + // fixed and merely dimmer. Each cell gets its own phase from + // its own hash, or the whole field blinks in unison. let phase = cell_hash(col, row, cfg.seed, 1) * std::f32::consts::TAU; - let a = 0.55 + 0.45 * (t * 1.6 + phase).sin(); + let a = 0.5 + 0.5 * (t * 1.6 + phase).sin(); paint.set_alpha_f(paint.alpha_f() * a); } @@ -1205,6 +1208,19 @@ mod pixel_grid_tests { assert_eq!(tile_spacing(&BackgroundPreset::PixelGrid(c)), 40.0); } + /// `twinkle` has to reach both ends. A cell that only dips to 10 % still + /// reads as a permanent dot: the field looks fixed, just dimmer. + #[test] + fn twinkle_spans_the_whole_opacity_range() { + let phase = 0.0f32; + let alpha = |t: f32| 0.5 + 0.5 * (t * 1.6 + phase).sin(); + let samples: Vec = (0..400).map(|i| alpha(i as f32 * 0.01)).collect(); + let lo = samples.iter().cloned().fold(f32::MAX, f32::min); + let hi = samples.iter().cloned().fold(f32::MIN, f32::max); + assert!(lo < 0.01, "must fade all the way out, floor was {lo}"); + assert!(hi > 0.99, "must come all the way back, ceiling was {hi}"); + } + /// Degenerate configs must be inert, not panic: an empty palette has /// nothing to draw with, and a zero size no area to draw. #[test]