1use edit::EditProject;
15use leptos::prelude::*;
16
17#[derive(Clone, Debug, PartialEq)]
20pub struct SegmentSpan {
21 pub index: usize,
23 pub start_fraction: f64,
25 pub width_fraction: f64,
27 pub source_start: u64,
29 pub source_end: u64,
31 pub timescale: f64,
33 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#[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#[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"); }
148
149 #[test]
150 fn segments_are_proportional_and_contiguous() {
151 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 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 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 let p = project_with(vec![
174 TimelineSegment::new(0, 300), TimelineSegment::with_speed(300, 900, 2.0), ]);
177 let spans = segment_spans(&p);
178 assert!((spans[0].width_fraction - spans[1].width_fraction).abs() < 1e-6);
180 }
181}