1use edit::EditProject;
13use edit::zoom::ZoomId;
14use leptos::prelude::*;
15
16use crate::editor_ipc::EditorStatus;
17
18#[derive(Clone, Debug, PartialEq)]
21pub struct ZoomBlock {
22 pub id: ZoomId,
24 pub start_fraction: f64,
26 pub width_fraction: f64,
28 pub amount: f64,
30 pub label: String,
32}
33
34#[allow(
35 clippy::cast_precision_loss,
36 reason = "frame counts are well under 2^52; u64→f64 is lossless at these magnitudes"
37)]
38fn fraction(part: u64, total: u64) -> f64 {
39 if total == 0 {
40 return 0.0;
41 }
42 part as f64 / total as f64
43}
44
45#[must_use]
48pub fn zoom_spans(project: &EditProject) -> Vec<ZoomBlock> {
49 let total = project.project_duration();
50 project
51 .zooms
52 .iter()
53 .map(|z| ZoomBlock {
54 id: z.id,
55 start_fraction: fraction(z.start, total),
56 width_fraction: fraction(z.len(), total),
57 amount: z.amount,
58 label: format!("{:.1}×", z.amount),
59 })
60 .collect()
61}
62
63#[component]
67pub fn ZoomLane() -> impl IntoView {
68 let project = use_context::<RwSignal<Option<EditProject>>>();
69 let history = use_context::<StoredValue<Option<edit::History>>>();
70 let status = use_context::<RwSignal<EditorStatus>>()
71 .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
72 let selection =
73 use_context::<RwSignal<Option<ZoomId>>>().unwrap_or_else(|| RwSignal::new(None));
74 view! {
75 <div class="timeline-lane timeline-lane--zoom" aria-label="Zoom track">
76 <button
77 class="zoom-lane-add"
78 title="Add a zoom at the playhead"
79 on:click=move |_| {
80 if let (Some(p), Some(h)) = (project, history) {
81 crate::editor_edits::add_zoom_default(p, h, status.get_untracked().current_frame);
82 }
83 }
84 >
85 "+ Zoom"
86 </button>
87 <button
88 class="zoom-lane-add zoom-lane-add--cursor"
89 title="Add a zoom that punches in on the cursor at the playhead"
90 on:click=move |_| {
91 if let (Some(p), Some(h)) = (project, history) {
92 crate::editor_edits::add_zoom_at_cursor(p, h, status.get_untracked().current_frame);
93 }
94 }
95 >
96 "+ Cursor"
97 </button>
98 {move || {
99 let spans = project
100 .and_then(|signal| signal.get().as_ref().map(zoom_spans))
101 .unwrap_or_default();
102 spans
103 .into_iter()
104 .map(|block| {
105 let id = block.id;
106 let is_selected = move || selection.get() == Some(id);
107 let style = format!(
108 "left:{:.3}%;width:{:.3}%",
109 block.start_fraction * 100.0,
110 block.width_fraction * 100.0
111 );
112 view! {
113 <div
114 class="zoom-block"
115 class:zoom-block--selected=is_selected
116 style=style
117 on:click=move |_| selection.set(Some(id))
118 >
119 <span class="zoom-block-label">{block.label}</span>
120 <button
121 class="zoom-block-remove"
122 title="Remove zoom"
123 on:click=move |ev| {
124 ev.stop_propagation();
125 if let (Some(p), Some(h)) = (project, history) {
126 crate::editor_edits::remove_zoom(p, h, id);
127 selection.set(None);
128 }
129 }
130 >
131 "×"
132 </button>
133 </div>
134 }
135 })
136 .collect_view()
137 }}
138 </div>
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use edit::zoom::ZoomSegment;
146 use edit::{ClipRef, EditProject};
147 use std::path::PathBuf;
148
149 fn project() -> EditProject {
150 EditProject::from_recording(ClipRef::new(
152 PathBuf::from("/tmp/rec.mp4"),
153 1920,
154 1080,
155 30,
156 900,
157 ))
158 }
159
160 #[test]
161 fn empty_when_no_zooms() {
162 assert!(zoom_spans(&project()).is_empty());
163 }
164
165 #[test]
166 fn zoom_spans_are_proportional() {
167 let mut p = project();
168 p.zooms = vec![
169 ZoomSegment::manual(ZoomId(1), 0, 300, 1.5),
170 ZoomSegment::manual(ZoomId(2), 450, 900, 2.0),
171 ];
172 let spans = zoom_spans(&p);
173 assert_eq!(spans.len(), 2);
174 assert_eq!(spans[0].id, ZoomId(1));
175 assert!((spans[0].start_fraction).abs() < 1e-9);
176 assert!((spans[0].width_fraction - 1.0 / 3.0).abs() < 1e-6);
177 assert_eq!(spans[0].label, "1.5×");
178 assert!((spans[1].start_fraction - 0.5).abs() < 1e-6);
180 assert!((spans[1].width_fraction - 0.5).abs() < 1e-6);
181 assert_eq!(spans[1].label, "2.0×");
182 }
183}