Skip to content

Commit 562e5f4

Browse files
authored
feat(transition): add corner_reveal, a rectangular uncover anchored at a corner (#207)
* fix(background): make pixel_grid's twinkle reach both ends of the range The cell alpha oscillated over 0.10..1.00, so a cell never actually went out. A lattice whose cells only dip to a tenth still reads as a fixed field of permanent dots, just dimmer — which is not what an animated texture is for. 0.5 + 0.5 * sin gives the full 0 → 1 → 0. The phase still comes from each cell's own hash, or the whole field blinks in unison. The test pins the range rather than the fact that something moves: it samples 400 instants and asserts the floor goes under 0.01 and the ceiling over 0.99. "It animates" and "it reaches zero" are different claims. * feat(transition): add corner_reveal, a rectangular uncover anchored at a 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 travels 2160 -> 0 and the bottom 1480 -> 2152. Both moving edges advance at once, and the incoming scene sits still behind the growing window — it is uncovered, not pushed. None of the thirteen existing types expresses that. wipe_* moves a single full-width band on one axis, iris is a circle, and slide translates both frames together. The closest approximation, a dissolve, loses the whole gesture. `corner` selects which two edges are pinned: top_right (the measured default), top_left, bottom_right, bottom_left. It is inert for every other type. Five tests pin the geometry rather than describe it: the anchored edges never move, the travelling ones open monotonically in the direction the corner names, the ends are empty and full, each corner anchors its own pair, and out-of-range progress clamps instead of inverting the rectangle — an inverted rect clips to nothing and the transition would silently look like a cut.
1 parent 117d45e commit 562e5f4

4 files changed

Lines changed: 176 additions & 4 deletions

File tree

.claude/skills/rustmotion/SKILL.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,21 @@ Config types: `string`, `number`, `boolean`, `object`, `array`. Omitted override
732732
{ "type": "fade", "duration": 0.5 }
733733
```
734734

735-
**13 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `none`
735+
**14 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `corner_reveal`, `none`
736+
737+
`corner_reveal` uncovers the incoming scene through a rectangle anchored at one
738+
corner: two edges stay pinned to the frame, the other two travel until it fills.
739+
The incoming scene sits still behind the growing window — it is *uncovered*,
740+
not pushed, which is what separates it from `slide` and from the full-width
741+
`wipe_*` band.
742+
743+
```json
744+
{ "type": "corner_reveal", "duration": 0.5, "corner": "top_right",
745+
"easing": "ease_in_out" }
746+
```
747+
748+
`corner` takes `top_right` (default), `top_left`, `bottom_right`, `bottom_left`
749+
and is ignored by every other type.
736750

737751
Default duration: `0.5` seconds.
738752

crates/rustmotion-core/src/engine/transition.rs

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::engine::animator::ease;
2-
use crate::schema::{EasingType, PanBackground, TransitionType};
2+
use crate::schema::{EasingType, PanBackground, TransitionCorner, TransitionType};
33
use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, Path, Rect};
44

55
/// Composite two RGBA frames during a transition.
@@ -11,6 +11,7 @@ pub fn apply_transition(
1111
height: u32,
1212
progress: f64,
1313
transition_type: &TransitionType,
14+
corner: TransitionCorner,
1415
) -> Vec<u8> {
1516
let progress = progress.clamp(0.0, 1.0) as f32;
1617

@@ -35,6 +36,9 @@ pub fn apply_transition(
3536
TransitionType::Iris => iris_transition(frame_a, frame_b, width, height, progress),
3637
TransitionType::Slide => slide_transition(frame_a, frame_b, width, height, progress),
3738
TransitionType::Dissolve => dissolve_transition(frame_a, frame_b, width, height, progress),
39+
TransitionType::CornerReveal => {
40+
corner_reveal(frame_a, frame_b, width, height, progress, corner)
41+
}
3842
TransitionType::CameraPan => blend_fade(frame_a, frame_b, progress),
3943
TransitionType::None => {
4044
if progress < 0.5 {
@@ -46,6 +50,61 @@ pub fn apply_transition(
4650
}
4751
}
4852

53+
/// Reveal the incoming frame through a rectangle anchored at one corner.
54+
///
55+
/// Measured on a reference piece, over 15 frames (0.5 s): the right and top
56+
/// edges stay pinned to the frame while the left edge travels 2160 -> 0 and the
57+
/// bottom edge 1480 -> 2152. So it is not a wipe — `wipe_*` moves one
58+
/// full-width band — and not an `iris`, which is a circle. Both edges move at
59+
/// once, and the incoming scene sits still behind the growing window rather
60+
/// than sliding in: what arrives is *uncovered*, not pushed.
61+
fn corner_reveal(
62+
frame_a: &[u8],
63+
frame_b: &[u8],
64+
width: u32,
65+
height: u32,
66+
progress: f32,
67+
corner: TransitionCorner,
68+
) -> Vec<u8> {
69+
let mut surface = match create_skia_surface(width, height) {
70+
Some(s) => s,
71+
None => return blend_fade(frame_a, frame_b, progress),
72+
};
73+
let img_a = match frame_to_image(frame_a, width, height) {
74+
Some(i) => i,
75+
None => return blend_fade(frame_a, frame_b, progress),
76+
};
77+
let img_b = match frame_to_image(frame_b, width, height) {
78+
Some(i) => i,
79+
None => return blend_fade(frame_a, frame_b, progress),
80+
};
81+
82+
let (w, h) = (width as f32, height as f32);
83+
let rect = corner_rect(corner, w, h, progress);
84+
85+
let canvas = surface.canvas();
86+
canvas.draw_image(&img_a, (0.0, 0.0), None);
87+
canvas.save();
88+
canvas.clip_rect(rect, skia_safe::ClipOp::Intersect, false);
89+
canvas.draw_image(&img_b, (0.0, 0.0), None);
90+
canvas.restore();
91+
92+
surface_to_pixels(surface, width, height)
93+
}
94+
95+
/// The revealed rectangle at `progress`, anchored so that two edges stay on the
96+
/// frame and two travel.
97+
fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_safe::Rect {
98+
let p = progress.clamp(0.0, 1.0);
99+
let (rw, rh) = (w * p, h * p);
100+
match corner {
101+
TransitionCorner::TopRight => skia_safe::Rect::from_xywh(w - rw, 0.0, rw, rh),
102+
TransitionCorner::TopLeft => skia_safe::Rect::from_xywh(0.0, 0.0, rw, rh),
103+
TransitionCorner::BottomRight => skia_safe::Rect::from_xywh(w - rw, h - rh, rw, rh),
104+
TransitionCorner::BottomLeft => skia_safe::Rect::from_xywh(0.0, h - rh, rw, rh),
105+
}
106+
}
107+
49108
fn blend_fade(frame_a: &[u8], frame_b: &[u8], progress: f32) -> Vec<u8> {
50109
let inv = 1.0 - progress;
51110
frame_a
@@ -717,3 +776,73 @@ mod camera_pan_tests {
717776
);
718777
}
719778
}
779+
780+
#[cfg(test)]
781+
mod corner_reveal_tests {
782+
use super::*;
783+
784+
/// Two edges stay on the frame, two travel. Measured on the reference
785+
/// piece: the right and top edges never move while the left runs
786+
/// 2160 -> 0 and the bottom 1480 -> 2152, over 15 frames.
787+
#[test]
788+
fn the_anchored_edges_never_move() {
789+
for p in [0.05, 0.3, 0.5, 0.8, 1.0] {
790+
let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
791+
assert!((r.right - 1920.0).abs() < 1e-3, "right edge moved at {p}");
792+
assert!(r.top.abs() < 1e-3, "top edge moved at {p}");
793+
}
794+
}
795+
796+
/// …and the travelling edges do move, monotonically, in the direction the
797+
/// corner names.
798+
#[test]
799+
fn the_travelling_edges_open_from_the_corner() {
800+
let at = |p| corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
801+
let (a, b, c) = (at(0.2), at(0.5), at(0.9));
802+
assert!(
803+
a.left > b.left && b.left > c.left,
804+
"left edge must travel left"
805+
);
806+
assert!(
807+
a.bottom < b.bottom && b.bottom < c.bottom,
808+
"bottom must travel down"
809+
);
810+
}
811+
812+
/// The ends are the whole point: nothing revealed at 0, everything at 1.
813+
#[test]
814+
fn it_starts_empty_and_ends_full() {
815+
let empty = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 0.0);
816+
assert_eq!((empty.width(), empty.height()), (0.0, 0.0));
817+
let full = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 1.0);
818+
assert_eq!(
819+
(full.left, full.top, full.right, full.bottom),
820+
(0.0, 0.0, 1920.0, 1080.0)
821+
);
822+
}
823+
824+
/// Each corner anchors its own two edges — otherwise `corner` is decoration.
825+
#[test]
826+
fn every_corner_anchors_its_own_edges() {
827+
let (w, h, p) = (1920.0f32, 1080.0f32, 0.4);
828+
let tl = corner_rect(TransitionCorner::TopLeft, w, h, p);
829+
assert!(tl.left.abs() < 1e-3 && tl.top.abs() < 1e-3);
830+
let br = corner_rect(TransitionCorner::BottomRight, w, h, p);
831+
assert!((br.right - w).abs() < 1e-3 && (br.bottom - h).abs() < 1e-3);
832+
let bl = corner_rect(TransitionCorner::BottomLeft, w, h, p);
833+
assert!(bl.left.abs() < 1e-3 && (bl.bottom - h).abs() < 1e-3);
834+
}
835+
836+
/// Progress outside 0..1 must clamp, not invert the rectangle: a negative
837+
/// width would make the clip empty and the transition would look like a cut.
838+
#[test]
839+
fn out_of_range_progress_clamps() {
840+
for p in [-0.5, 1.5] {
841+
let r = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p);
842+
assert!(
843+
r.width() >= 0.0 && r.height() >= 0.0,
844+
"inverted rect at {p}"
845+
);
846+
}
847+
}
848+
}

crates/rustmotion-core/src/schema/scenario.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,11 +593,27 @@ pub struct SceneLayout {
593593
pub padding: Option<f32>,
594594
}
595595

596+
/// The corner a `corner_reveal` is anchored to.
597+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
598+
#[serde(rename_all = "snake_case")]
599+
pub enum TransitionCorner {
600+
/// Measured default: the reference piece grows its reveal from here, with
601+
/// the right and top edges pinned and the left and bottom edges travelling.
602+
#[default]
603+
TopRight,
604+
TopLeft,
605+
BottomRight,
606+
BottomLeft,
607+
}
608+
596609
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
597610
#[serde(deny_unknown_fields)]
598611
pub struct Transition {
599612
#[serde(rename = "type")]
600613
pub transition_type: TransitionType,
614+
/// Which corner a `corner_reveal` grows from. Ignored by every other type.
615+
#[serde(default)]
616+
pub corner: TransitionCorner,
601617
#[serde(default = "default_transition_duration")]
602618
pub duration: f64,
603619
#[serde(default = "default_transition_easing")]
@@ -641,6 +657,7 @@ pub enum TransitionType {
641657
Iris,
642658
Slide,
643659
Dissolve,
660+
CornerReveal,
644661
CameraPan,
645662
None,
646663
}

crates/rustmotion/src/encode/video/tasks.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::engine::transition::{apply_transition, camera_pan_transition};
22
use crate::error::{Result, RustmotionError};
33
use crate::schema::{
4-
EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionType, VideoConfig,
5-
ViewType,
4+
EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionCorner,
5+
TransitionType, VideoConfig, ViewType,
66
};
77

88
/// Description of what to render for a specific frame
@@ -40,6 +40,8 @@ pub enum FrameTask {
4040
scene_a_total_frames: u32,
4141
scene_b_total_frames: u32,
4242
transition_type: TransitionType,
43+
/// Which corner a `corner_reveal` grows from; inert for every other type.
44+
corner: TransitionCorner,
4345
transition_duration: f64,
4446
easing: EasingType,
4547
},
@@ -69,6 +71,8 @@ pub enum FrameTask {
6971
view_b_idx: usize,
7072
frame_in_transition: u32,
7173
transition_type: TransitionType,
74+
/// Which corner a `corner_reveal` grows from; inert for every other type.
75+
corner: TransitionCorner,
7276
transition_duration: f64,
7377
easing: EasingType,
7478
},
@@ -161,6 +165,7 @@ pub fn render_frame_task_scaled(
161165
scene_a_total_frames,
162166
scene_b_total_frames,
163167
transition_type,
168+
corner,
164169
transition_duration,
165170
easing,
166171
} => {
@@ -294,6 +299,7 @@ pub fn render_frame_task_scaled(
294299
scaled_h,
295300
progress,
296301
transition_type,
302+
*corner,
297303
);
298304
apply_post_effects(
299305
&mut composited,
@@ -348,6 +354,7 @@ pub fn render_frame_task_scaled(
348354
view_b_idx,
349355
frame_in_transition,
350356
transition_type,
357+
corner,
351358
transition_duration,
352359
easing: _,
353360
} => {
@@ -377,6 +384,7 @@ pub fn render_frame_task_scaled(
377384
scaled_h,
378385
progress,
379386
transition_type,
387+
*corner,
380388
);
381389
// By symmetry with `SlideTransition` (which applies scene_b's
382390
// effects to the blended result, "the transition is the entry
@@ -548,6 +556,7 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec<FrameTask> {
548556
view_b_idx: view_idx,
549557
frame_in_transition: f,
550558
transition_type: transition.transition_type.clone(),
559+
corner: transition.corner,
551560
transition_duration: transition.duration,
552561
easing: transition.easing.clone(),
553562
});
@@ -676,6 +685,7 @@ fn build_slide_view_tasks(
676685
scene_a_total_frames: scene_frames,
677686
scene_b_total_frames: scene_b_frames,
678687
transition_type: transition.transition_type.clone(),
688+
corner: transition.corner,
679689
transition_duration: outgoing_effective_duration,
680690
easing: easing.clone(),
681691
});
@@ -867,6 +877,7 @@ pub(super) fn build_slot_frame_tasks(
867877
view_b_idx: *view_idx,
868878
frame_in_transition: f,
869879
transition_type: transition.transition_type.clone(),
880+
corner: transition.corner,
870881
transition_duration: transition.duration,
871882
easing: transition.easing.clone(),
872883
});
@@ -936,6 +947,7 @@ pub(super) fn build_scene_frame_tasks_in_view(
936947
scene_a_total_frames: scene_frames,
937948
scene_b_total_frames: scene_b_frames,
938949
transition_type: transition.transition_type.clone(),
950+
corner: transition.corner,
939951
transition_duration: outgoing_effective_duration,
940952
easing: easing.clone(),
941953
});

0 commit comments

Comments
 (0)