1use 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
21pub 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#[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#[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}