Skip to main content

app_ui/
zoom_lane.rs

1//! Zoom lane — author cinematic punch-ins on the timeline (ED.12 / M-EDIT).
2//!
3//! The lane that sits the zoom regions ([`edit::zoom::ZoomSegment`]) beneath
4//! the video track, one block per region, laid out by the same
5//! fraction-of-duration math as the [filmstrip](crate::filmstrip) so every
6//! lane stays pixel-aligned. Adding a block here writes a `ZoomSegment` the
7//! [zoom engine](../../edit/zoom_anim/index.html) (ED.16) compiles to a
8//! push-in at preview + export. Dragging a block's body / edges to
9//! move + resize joins the deferred gesture pass; this chunk is the
10//! responsive layout + add / select / remove.
11
12use edit::EditProject;
13use edit::zoom::ZoomId;
14use leptos::prelude::*;
15
16use crate::editor_ipc::EditorStatus;
17
18/// A laid-out zoom region: its fractional position across the project plus
19/// its amount and a label.
20#[derive(Clone, Debug, PartialEq)]
21pub struct ZoomBlock {
22    /// Stable id of the underlying [`edit::zoom::ZoomSegment`].
23    pub id: ZoomId,
24    /// Left edge as a fraction `0..=1` of the project duration.
25    pub start_fraction: f64,
26    /// Width as a fraction `0..=1` of the project duration.
27    pub width_fraction: f64,
28    /// Zoom factor at the hold (e.g. `1.6`).
29    pub amount: f64,
30    /// Amount label, e.g. `"1.6×"`.
31    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/// Lay out the project's zoom regions as proportional blocks (left + width
46/// fractions of the project duration), in list order.
47#[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/// The zoom lane: the project's zoom regions as selectable blocks, plus a
64/// "+ Zoom" affordance that drops a default region at the playhead. Reads
65/// the project, edit history, playhead, and zoom selection from context.
66#[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        // 900 project frames @ 30 fps.
151        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        // Second block: starts halfway, runs to the end.
179        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}