1use crate::model::{Id, Project};
9use crate::ui::duration_text;
10use eframe::egui;
11
12#[derive(Default)]
13pub struct RetimeUi {
14 pub open: bool,
15 pub percent: f64,
17 pub reverse: bool,
18 pub last_clip: Option<Id>,
19 pub note: String,
20}
21
22const NO_ROOM: &str = "No room: the new length would collide with a neighbouring clip.";
23
24fn apply_speed(
26 project: &mut Project,
27 selection: &[Id],
28 speed: f64,
29 reverse: bool,
30 note: &mut String,
31 undo: &mut dyn FnMut(&Project),
32) -> bool {
33 let pre = project.clone();
34 if project.set_speed(selection, speed, reverse) {
35 undo(&pre);
36 note.clear();
37 true
38 } else {
39 *note = NO_ROOM.into();
40 false
41 }
42}
43
44fn apply_freeze(project: &mut Project, selection: &[Id], playhead: f64, undo: &mut dyn FnMut(&Project)) -> bool {
47 let sel: Vec<Id> =
48 selection.iter().copied().filter(|&id| project.clip(id).is_some_and(|c| c.contains(playhead))).collect();
49 if sel.is_empty() {
50 return false;
51 }
52 let pre = project.clone();
53 if project.freeze_at(playhead, &sel).is_empty() {
54 false
55 } else {
56 undo(&pre);
57 true
58 }
59}
60
61fn apply_unfreeze(project: &mut Project, selection: &[Id], undo: &mut dyn FnMut(&Project)) -> bool {
62 let ids = project.expand_links(selection);
63 if !ids.iter().any(|&id| project.clip(id).is_some_and(|c| c.freeze.is_some())) {
64 return false;
65 }
66 let pre = project.clone();
67 undo(&pre);
68 for id in ids {
69 if let Some(c) = project.clip_mut(id) {
70 c.freeze = None;
71 }
72 }
73 true
74}
75
76pub fn show(
77 ctx: &egui::Context,
78 state: &mut RetimeUi,
79 project: &mut Project,
80 selection: &[Id],
81 playhead: f64,
82 undo: &mut dyn FnMut(&Project),
83) -> bool {
84 if !state.open {
85 state.last_clip = None;
86 return false;
87 }
88 let first = selection.iter().copied().find(|&id| project.clip(id).is_some());
89 if state.last_clip != first {
90 state.last_clip = first;
91 state.note.clear();
92 if let Some(c) = first.and_then(|id| project.clip(id)) {
93 state.percent = c.speed * 100.0;
94 state.reverse = c.reverse;
95 }
96 }
97 let mut changed = false;
98 let mut open = state.open;
99 egui::Window::new("Speed / Retime").open(&mut open).resizable(false).show(ctx, |ui| {
100 let Some(clip) = first.and_then(|id| project.clip(id)).cloned() else {
101 ui.weak("Select a clip in the timeline.");
102 return;
103 };
104 ui.horizontal(|ui| {
105 ui.label("Speed");
106 ui.add(egui::DragValue::new(&mut state.percent).range(1.0..=10000.0).suffix(" %").speed(1.0));
107 });
108 ui.horizontal(|ui| {
109 for p in [25.0, 50.0, 100.0, 200.0, 400.0] {
110 if ui.small_button(format!("{p:.0} %")).clicked() {
111 state.percent = p;
112 }
113 }
114 });
115 ui.checkbox(&mut state.reverse, "Reverse");
116 let new_dur =
117 if clip.freeze.is_some() { clip.duration } else { clip.src_len() / (state.percent / 100.0).max(0.0001) };
118 ui.weak(format!("Duration: {} → {}", duration_text(clip.duration), duration_text(new_dur)));
119 if let Some(f) = clip.freeze {
120 ui.weak(format!("Frozen at source {}", duration_text(f)));
121 }
122 ui.horizontal(|ui| {
123 if ui.button("Apply").clicked() {
124 changed |= apply_speed(project, selection, state.percent / 100.0, state.reverse, &mut state.note, undo);
125 }
126 if ui
127 .add_enabled(clip.contains(playhead), egui::Button::new("Freeze frame at playhead"))
128 .on_disabled_hover_text("Move the playhead over the clip to freeze a frame.")
129 .clicked()
130 && apply_freeze(project, selection, playhead, undo)
131 {
132 changed = true;
133 }
134 if clip.freeze.is_some() && ui.button("Unfreeze").clicked() && apply_unfreeze(project, selection, undo) {
135 changed = true;
136 }
137 });
138 if !state.note.is_empty() {
139 ui.colored_label(ui.visuals().warn_fg_color, &state.note);
140 }
141 });
142 state.open = open;
143 changed
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::model::{Asset, ClipKind};
150
151 fn project_10s() -> (Project, Id) {
152 let a = Asset {
153 id: 0,
154 path: r"C:\media\a.mp4".into(),
155 kind: ClipKind::Video,
156 duration: 10.0,
157 width: 1280,
158 height: 720,
159 fps: 30.0,
160 audio_streams: Vec::new(),
161 codec: String::new(),
162 folder: String::new(),
163 tags: Vec::new(),
164 label: 0,
165 description: String::new(),
166 };
167 let p = Project::from_media(a);
168 let id = p.tracks[0].clips[0].id;
169 (p, id)
170 }
171
172 #[test]
173 fn apply_2x_halves_duration_one_undo() {
174 let (mut p, id) = project_10s();
175 let mut undos = 0;
176 let mut note = String::new();
177 let mut undo = |pre: &Project| {
178 undos += 1;
179 assert!((pre.clip(id).unwrap().duration - 10.0).abs() < 1e-9, "undo sees the pre-change project");
180 };
181 assert!(apply_speed(&mut p, &[id], 2.0, false, &mut note, &mut undo));
182 assert_eq!(undos, 1);
183 assert!(note.is_empty());
184 assert!((p.clip(id).unwrap().duration - 5.0).abs() < 1e-9);
185 }
186
187 #[test]
188 fn collision_leaves_note_and_no_undo() {
189 let (mut p, id) = project_10s();
190 p.tracks[0].clips.push(crate::model::Clip::new(999, ClipKind::Text, "block", 10.0, 2.0));
192 let mut undos = 0;
193 let mut note = String::new();
194 assert!(!apply_speed(&mut p, &[id], 0.5, false, &mut note, &mut |_| undos += 1));
195 assert_eq!(undos, 0);
196 assert_eq!(note, NO_ROOM);
197 assert!((p.clip(id).unwrap().duration - 10.0).abs() < 1e-9);
198 }
199
200 #[test]
201 fn freeze_and_unfreeze() {
202 let (mut p, id) = project_10s();
203 let mut undos = 0;
204 assert!(apply_freeze(&mut p, &[id], 4.0, &mut |_| undos += 1));
205 assert_eq!(undos, 1);
206 let frozen: Vec<Id> = p.all_clips().filter(|(_, c)| c.freeze.is_some()).map(|(_, c)| c.id).collect();
207 assert!(!frozen.is_empty());
208 assert!(apply_unfreeze(&mut p, &frozen, &mut |_| undos += 1));
209 assert_eq!(undos, 2);
210 assert!(p.all_clips().all(|(_, c)| c.freeze.is_none()));
211 assert!(!apply_unfreeze(&mut p, &[id], &mut |_| undos += 1));
213 assert_eq!(undos, 2);
214 }
215
216 #[test]
218 fn freeze_outside_clip_is_a_no_op() {
219 let (mut p, id) = project_10s();
220 p.clip_mut(id).unwrap().start = 10.0; let mut undos = 0;
222 assert!(!apply_freeze(&mut p, &[id], 0.0, &mut |_| undos += 1));
223 assert_eq!(undos, 0);
224 assert!(p.all_clips().all(|(_, c)| c.freeze.is_none()));
225 assert!(apply_freeze(&mut p, &[id], 12.0, &mut |_| undos += 1));
227 assert_eq!(undos, 1);
228 assert!(p.all_clips().any(|(_, c)| c.freeze.is_some_and(|f| f >= 0.0)));
229 }
230
231 #[test]
233 fn show_headless() {
234 let (mut p, id) = project_10s();
235 let ctx = egui::Context::default();
236 for sel in [vec![], vec![id]] {
237 let mut state = RetimeUi { open: true, ..Default::default() };
238 for _ in 0..2 {
239 let _ = ctx.run(egui::RawInput::default(), |ctx| {
240 let mut undo = |_: &Project| {};
241 assert!(!show(ctx, &mut state, &mut p, &sel, 0.0, &mut undo));
242 });
243 }
244 if !sel.is_empty() {
245 assert_eq!(state.last_clip, Some(id));
246 assert!((state.percent - 100.0).abs() < 1e-9);
247 }
248 }
249 }
250}