Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .claude/skills/rustmotion/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
131 changes: 130 additions & 1 deletion crates/rustmotion-core/src/engine/transition.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,6 +11,7 @@ pub fn apply_transition(
height: u32,
progress: f64,
transition_type: &TransitionType,
corner: TransitionCorner,
) -> Vec<u8> {
let progress = progress.clamp(0.0, 1.0) as f32;

Expand All @@ -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 {
Expand All @@ -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<u8> {
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<u8> {
let inv = 1.0 - progress;
frame_a
Expand Down Expand Up @@ -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}"
);
}
}
}
17 changes: 17 additions & 0 deletions crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,11 +593,27 @@ pub struct SceneLayout {
pub padding: Option<f32>,
}

/// 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")]
Expand Down Expand Up @@ -641,6 +657,7 @@ pub enum TransitionType {
Iris,
Slide,
Dissolve,
CornerReveal,
CameraPan,
None,
}
Expand Down
16 changes: 14 additions & 2 deletions crates/rustmotion/src/encode/video/tasks.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -161,6 +165,7 @@ pub fn render_frame_task_scaled(
scene_a_total_frames,
scene_b_total_frames,
transition_type,
corner,
transition_duration,
easing,
} => {
Expand Down Expand Up @@ -294,6 +299,7 @@ pub fn render_frame_task_scaled(
scaled_h,
progress,
transition_type,
*corner,
);
apply_post_effects(
&mut composited,
Expand Down Expand Up @@ -348,6 +354,7 @@ pub fn render_frame_task_scaled(
view_b_idx,
frame_in_transition,
transition_type,
corner,
transition_duration,
easing: _,
} => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -548,6 +556,7 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec<FrameTask> {
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(),
});
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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(),
});
Expand Down
20 changes: 18 additions & 2 deletions crates/rustmotion/src/engine/render/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<f32> = (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]
Expand Down
Loading