Skip to main content

app_ui/
filmstrip.rs

1//! Video track filmstrip + clip selection (ED.9 / M-EDIT).
2//!
3//! [`segment_spans`] is the pure layout: each [`edit::TimelineSegment`]
4//! becomes a proportional span (`start_fraction` / `width_fraction`) of the
5//! project so the video lane lays out responsively at any width. The
6//! [`VideoFilmstrip`] component renders those spans as selectable clip
7//! blocks; the selection (a `RwSignal<Option<usize>>` in context) drives
8//! the inspector (ED.18).
9//!
10//! Per-clip thumbnail images decode + downscale through `EditorVideoStream`
11//! and land with the render-integration pass; this chunk is the responsive
12//! segment layout + selection + duration labels.
13
14use edit::EditProject;
15use leptos::prelude::*;
16
17/// A laid-out segment: its fractional position across the project plus its
18/// source range and a duration label.
19#[derive(Clone, Debug, PartialEq)]
20pub struct SegmentSpan {
21    /// Index into [`EditProject::segments`].
22    pub index: usize,
23    /// Left edge as a fraction `0..=1` of the project duration.
24    pub start_fraction: f64,
25    /// Width as a fraction `0..=1` of the project duration.
26    pub width_fraction: f64,
27    /// Source in-point (frame).
28    pub source_start: u64,
29    /// Source out-point (frame).
30    pub source_end: u64,
31    /// Playback speed multiplier.
32    pub timescale: f64,
33    /// `m:ss` duration label (project time).
34    pub label: String,
35}
36
37#[allow(
38    clippy::cast_precision_loss,
39    reason = "frame counts are well under 2^52; u64→f64 is lossless at these magnitudes"
40)]
41fn fraction(part: u64, total: u64) -> f64 {
42    if total == 0 {
43        return 0.0;
44    }
45    part as f64 / total as f64
46}
47
48fn label_for(project_len: u64, fps: u32) -> String {
49    let secs = project_len / u64::from(fps.max(1));
50    format!("{}:{:02}", secs / 60, secs % 60)
51}
52
53/// Lay out the project's segments as proportional spans (left + width
54/// fractions), in timeline order.
55#[must_use]
56pub fn segment_spans(project: &EditProject) -> Vec<SegmentSpan> {
57    let total = project.project_duration();
58    let fps = project.project_fps;
59    let mut acc = 0u64;
60    project
61        .segments
62        .iter()
63        .enumerate()
64        .map(|(index, seg)| {
65            let project_len = seg.project_len();
66            let span = SegmentSpan {
67                index,
68                start_fraction: fraction(acc, total),
69                width_fraction: fraction(project_len, total),
70                source_start: seg.source_start,
71                source_end: seg.source_end,
72                timescale: seg.timescale,
73                label: label_for(project_len, fps),
74            };
75            acc += project_len;
76            span
77        })
78        .collect()
79}
80
81/// The video lane: the project's segments as selectable clip blocks,
82/// positioned by fraction so the lane is responsive. Click selects a clip
83/// (drives the inspector). Reads the project + selection from context.
84#[component]
85pub fn VideoFilmstrip() -> impl IntoView {
86    let project = use_context::<RwSignal<Option<EditProject>>>();
87    let selection = use_context::<RwSignal<Option<usize>>>().unwrap_or_else(|| RwSignal::new(None));
88    view! {
89        <div class="timeline-lane timeline-lane--video" aria-label="Video track">
90            {move || {
91                let spans = project
92                    .and_then(|signal| signal.get().as_ref().map(segment_spans))
93                    .unwrap_or_default();
94                spans
95                    .into_iter()
96                    .map(|span| {
97                        let index = span.index;
98                        let is_selected = move || selection.get() == Some(index);
99                        let style = format!(
100                            "left:{:.3}%;width:{:.3}%",
101                            span.start_fraction * 100.0,
102                            span.width_fraction * 100.0
103                        );
104                        view! {
105                            <button
106                                class="filmstrip-clip"
107                                class:filmstrip-clip--selected=is_selected
108                                style=style
109                                on:click=move |_| selection.set(Some(index))
110                            >
111                                <span class="filmstrip-clip-label">{span.label}</span>
112                            </button>
113                        }
114                    })
115                    .collect_view()
116            }}
117        </div>
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use edit::{ClipRef, EditProject, TimelineSegment};
125    use std::path::PathBuf;
126
127    fn project_with(segments: Vec<TimelineSegment>) -> EditProject {
128        let mut p = EditProject::from_recording(ClipRef::new(
129            PathBuf::from("/tmp/rec.mp4"),
130            1920,
131            1080,
132            30,
133            900,
134        ));
135        p.segments = segments;
136        p
137    }
138
139    #[test]
140    fn single_segment_spans_full_width() {
141        let p = project_with(vec![TimelineSegment::new(0, 900)]);
142        let spans = segment_spans(&p);
143        assert_eq!(spans.len(), 1);
144        assert!((spans[0].start_fraction).abs() < 1e-9);
145        assert!((spans[0].width_fraction - 1.0).abs() < 1e-9);
146        assert_eq!(spans[0].label, "0:30"); // 900 frames @ 30 fps
147    }
148
149    #[test]
150    fn segments_are_proportional_and_contiguous() {
151        // 300 + 600 = 900 project frames → 1/3 and 2/3.
152        let p = project_with(vec![
153            TimelineSegment::new(0, 300),
154            TimelineSegment::new(300, 900),
155        ]);
156        let spans = segment_spans(&p);
157        assert_eq!(spans.len(), 2);
158        assert!((spans[0].start_fraction - 0.0).abs() < 1e-9);
159        assert!((spans[0].width_fraction - 1.0 / 3.0).abs() < 1e-6);
160        // Second clip starts where the first ends.
161        assert!((spans[1].start_fraction - 1.0 / 3.0).abs() < 1e-6);
162        assert!((spans[1].width_fraction - 2.0 / 3.0).abs() < 1e-6);
163        // Spans tile the lane (start + width of the last == 1.0).
164        let end = spans[1].start_fraction + spans[1].width_fraction;
165        assert!((end - 1.0).abs() < 1e-6);
166        assert_eq!(spans[0].index, 0);
167        assert_eq!(spans[1].index, 1);
168    }
169
170    #[test]
171    fn speed_segment_width_reflects_project_length() {
172        // A 2× segment occupies half its source span in project time.
173        let p = project_with(vec![
174            TimelineSegment::new(0, 300),               // 300 project frames
175            TimelineSegment::with_speed(300, 900, 2.0), // 600 src → 300 project frames
176        ]);
177        let spans = segment_spans(&p);
178        // Both occupy 300 project frames → equal widths.
179        assert!((spans[0].width_fraction - spans[1].width_fraction).abs() < 1e-6);
180    }
181}