Skip to main content

app_ui/
zoom_dopesheet.rs

1//! Zoom dopesheet — keyframes + Easy Ease for the selected zoom (ED.13).
2//!
3//! The animator's dope sheet: the selected [zoom](crate::zoom_lane)'s scale
4//! curve laid out as keyframes (identity → full → identity), plus a row of
5//! easing presets. The default and one-click favorite is **Easy Ease**
6//! (`InOutCubic`) — accelerate off the wide shot, settle into the detail.
7//! The keyframe model is the pure [`zoom_keyframes`]
8//! (the `Track`-shaped view of the engine's ramp); choosing an ease commits
9//! a `SetZoomEase` through the shared [`edit::History`]. The marker plot is
10//! authoring/inspection; the eased motion itself renders via the ED.16
11//! engine.
12
13use edit::EditProject;
14use edit::zoom::{EditEase, ZoomId, ZoomSegment};
15use edit::zoom_anim::{default_ramp_frames, zoom_keyframes};
16use leptos::prelude::*;
17
18type ProjectSignal = Option<RwSignal<Option<EditProject>>>;
19type HistoryStore = Option<StoredValue<Option<edit::History>>>;
20
21/// Ease presets, in display order — Easy Ease (`InOutCubic`) leads.
22pub const EASES: [(EditEase, &str); 5] = [
23    (EditEase::InOutCubic, "Easy Ease"),
24    (EditEase::Linear, "Linear"),
25    (EditEase::InCubic, "In"),
26    (EditEase::OutCubic, "Out"),
27    (EditEase::InOutSine, "Sine"),
28];
29
30/// The zoom with `id`, if present. Pure.
31#[must_use]
32pub fn selected_zoom(project: &EditProject, id: ZoomId) -> Option<ZoomSegment> {
33    project.zooms.iter().find(|z| z.id == id).copied()
34}
35
36fn frac(part: u64, total: u64) -> f64 {
37    let n = u32::try_from(part).unwrap_or(u32::MAX);
38    let d = u32::try_from(total.max(1)).unwrap_or(u32::MAX);
39    f64::from(n) / f64::from(d)
40}
41
42fn keyframe_markers(seg: ZoomSegment, fps: u32) -> AnyView {
43    let len = seg.len().max(1);
44    zoom_keyframes(&seg, default_ramp_frames(fps))
45        .into_iter()
46        .map(|kf| {
47            let pos = frac(kf.frame.saturating_sub(seg.start), len) * 100.0;
48            view! {
49                <span
50                    class="dopesheet-key"
51                    style=format!("left:{pos:.2}%")
52                    title=format!("frame {} · {:.2}×", kf.frame, kf.scale)
53                ></span>
54            }
55        })
56        .collect_view()
57        .into_any()
58}
59
60fn dopesheet_body(
61    seg: ZoomSegment,
62    fps: u32,
63    id: ZoomId,
64    project: ProjectSignal,
65    history: HistoryStore,
66) -> AnyView {
67    let current = seg.ease;
68    let eases = EASES
69        .into_iter()
70        .map(|(ease, label)| {
71            let mut class = String::from("ease-btn");
72            if ease == current {
73                class.push_str(" ease-btn--active");
74            }
75            view! {
76                <button
77                    class=class
78                    on:click=move |_| {
79                        if let (Some(p), Some(h)) = (project, history) {
80                            crate::editor_edits::set_zoom_ease(p, h, id, ease);
81                        }
82                    }
83                >
84                    {label}
85                </button>
86            }
87        })
88        .collect_view();
89    view! {
90        <div class="dopesheet-track">{keyframe_markers(seg, fps)}</div>
91        <div class="dopesheet-eases">{eases}</div>
92    }
93    .into_any()
94}
95
96/// The zoom dopesheet. Reads the zoom selection (ED.12), project, and edit
97/// history from context; tunes the selected zoom's keyframes + ease.
98#[component]
99pub fn ZoomDopesheet() -> impl IntoView {
100    let project = use_context::<RwSignal<Option<EditProject>>>();
101    let history = use_context::<StoredValue<Option<edit::History>>>();
102    let selection =
103        use_context::<RwSignal<Option<ZoomId>>>().unwrap_or_else(|| RwSignal::new(None));
104    view! {
105        <div class="dopesheet" aria-label="Zoom dopesheet">
106            {move || {
107                let selected = selection
108                    .get()
109                    .and_then(|id| {
110                        project
111                            .and_then(|s| s.get())
112                            .and_then(|p| selected_zoom(&p, id).map(|seg| (id, seg, p.project_fps)))
113                    });
114                match selected {
115                    Some((id, seg, fps)) => dopesheet_body(seg, fps, id, project, history),
116                    None => view! {
117                        <p class="dopesheet-hint">
118                            "Select a zoom block to tune its keyframes + ease."
119                        </p>
120                    }
121                    .into_any(),
122                }
123            }}
124        </div>
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use edit::ClipRef;
132    use std::path::PathBuf;
133
134    #[test]
135    fn eases_lead_with_easy_ease() {
136        assert_eq!(EASES.len(), 5);
137        assert_eq!(EASES[0].0, EditEase::InOutCubic);
138        assert_eq!(EASES[0].1, "Easy Ease");
139    }
140
141    #[test]
142    fn selected_zoom_finds_by_id() {
143        let mut p = EditProject::from_recording(ClipRef::new(
144            PathBuf::from("/tmp/a.mp4"),
145            1920,
146            1080,
147            30,
148            900,
149        ));
150        p.zooms = vec![
151            ZoomSegment::manual(ZoomId(1), 0, 100, 1.5),
152            ZoomSegment::manual(ZoomId(2), 200, 300, 2.0),
153        ];
154        assert_eq!(selected_zoom(&p, ZoomId(2)).map(|z| z.start), Some(200));
155        assert!(selected_zoom(&p, ZoomId(9)).is_none());
156    }
157}