1use crate::project::EditProject;
20use crate::segment::Frame;
21use crate::zoom::{ZoomMode, ZoomSegment};
22
23#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct ZoomTransform {
30 pub scale: f64,
32 pub center_x: f64,
34 pub center_y: f64,
36}
37
38impl ZoomTransform {
39 #[must_use]
41 pub fn identity() -> Self {
42 Self {
43 scale: 1.0,
44 center_x: 0.5,
45 center_y: 0.5,
46 }
47 }
48
49 #[must_use]
51 pub fn is_identity(self) -> bool {
52 (self.scale - 1.0).abs() < 1e-6
53 }
54}
55
56impl Default for ZoomTransform {
57 fn default() -> Self {
58 Self::identity()
59 }
60}
61
62#[must_use]
66pub fn default_ramp_frames(fps: u32) -> Frame {
67 (Frame::from(fps) * 6 / 10).max(1)
68}
69
70fn frac(num: Frame, den: Frame) -> f64 {
73 let n = u32::try_from(num).unwrap_or(u32::MAX);
74 let d = u32::try_from(den).unwrap_or(u32::MAX);
75 f64::from(n) / f64::from(d)
76}
77
78#[must_use]
85pub fn zoom_at(seg: &ZoomSegment, frame: Frame, ramp_frames: Frame) -> ZoomTransform {
86 if seg.is_empty() || !seg.contains(frame) {
87 return ZoomTransform::identity();
88 }
89 let (center_x, center_y) = match seg.mode {
90 ZoomMode::Manual { x, y } => (f64::from(x), f64::from(y)),
91 ZoomMode::Auto => (0.5, 0.5),
94 };
95 let len = seg.len();
96 let ramp = ramp_frames.min(len / 2);
97 let local = frame - seg.start;
98 let progress = if ramp == 0 {
99 1.0
100 } else if local < ramp {
101 seg.ease.eval(frac(local, ramp))
102 } else if local >= len - ramp {
103 seg.ease.eval(frac(len - local, ramp))
104 } else {
105 1.0
106 };
107 let scale = (1.0 + (seg.amount - 1.0) * progress).max(1.0);
111 ZoomTransform {
112 scale,
113 center_x,
114 center_y,
115 }
116}
117
118#[must_use]
123pub fn active_zoom_at(project: &EditProject, frame: Frame) -> ZoomTransform {
124 let ramp = default_ramp_frames(project.project_fps);
125 project
126 .zooms
127 .iter()
128 .find(|z| z.contains(frame))
129 .map_or_else(ZoomTransform::identity, |z| zoom_at(z, frame, ramp))
130}
131
132#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct ZoomKeyframe {
135 pub frame: Frame,
137 pub scale: f64,
139}
140
141#[must_use]
147pub fn zoom_keyframes(seg: &ZoomSegment, ramp_frames: Frame) -> Vec<ZoomKeyframe> {
148 if seg.is_empty() {
149 return Vec::new();
150 }
151 let ramp = ramp_frames.min(seg.len() / 2);
152 let amount = seg.amount.max(1.0);
155 let in_peak = seg.start + ramp;
156 let out_peak = seg.end.saturating_sub(ramp);
157 let mut kfs = vec![ZoomKeyframe {
158 frame: seg.start,
159 scale: 1.0,
160 }];
161 kfs.push(ZoomKeyframe {
162 frame: in_peak,
163 scale: amount,
164 });
165 if out_peak > in_peak {
166 kfs.push(ZoomKeyframe {
167 frame: out_peak,
168 scale: amount,
169 });
170 }
171 kfs.push(ZoomKeyframe {
172 frame: seg.end,
173 scale: 1.0,
174 });
175 kfs
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::clip::ClipRef;
182 use crate::zoom::{ZoomId, ZoomMode, ZoomSegment};
183 use std::path::PathBuf;
184
185 fn seg() -> ZoomSegment {
187 ZoomSegment::manual(ZoomId(1), 100, 200, 2.0)
188 }
189
190 #[test]
191 fn identity_outside_window() {
192 let z = seg();
193 assert!(zoom_at(&z, 99, 10).is_identity());
194 assert!(zoom_at(&z, 200, 10).is_identity());
195 assert_eq!(zoom_at(&z, 50, 10), ZoomTransform::identity());
196 }
197
198 #[test]
199 fn full_amount_during_hold() {
200 let t = zoom_at(&seg(), 150, 10);
202 assert!((t.scale - 2.0).abs() < 1e-9, "hold = full amount");
203 assert!((t.center_x - 0.5).abs() < 1e-9);
204 assert!((t.center_y - 0.5).abs() < 1e-9);
205 }
206
207 #[test]
208 fn ramp_in_is_monotonic_and_bounded() {
209 let z = seg();
210 let mut prev = 0.0;
211 for f in 100..110 {
212 let s = zoom_at(&z, f, 10).scale;
213 assert!((1.0 - 1e-9..=2.0 + 1e-9).contains(&s));
214 assert!(s + 1e-9 >= prev, "ramp-in non-decreasing at {f}");
215 prev = s;
216 }
217 assert!((zoom_at(&z, 100, 10).scale - 1.0).abs() < 1e-9);
219 }
220
221 #[test]
222 fn ramp_out_returns_toward_identity() {
223 let z = seg();
224 let full = zoom_at(&z, 190, 10).scale; let later = zoom_at(&z, 199, 10).scale; assert!((full - 2.0).abs() < 1e-9);
227 assert!(later < full, "ramp-out decreasing");
228 assert!(later >= 1.0 - 1e-9);
229 }
230
231 #[test]
232 fn ramp_clamped_to_half_window() {
233 let z = ZoomSegment::manual(ZoomId(2), 0, 10, 3.0);
235 let mid = zoom_at(&z, 5, 100).scale;
236 assert!(mid > 1.0 && mid <= 3.0 + 1e-9);
237 assert!((zoom_at(&z, 0, 100).scale - 1.0).abs() < 1e-9);
238 }
239
240 #[test]
241 fn manual_target_sets_center() {
242 let mut z = seg();
243 z.mode = ZoomMode::Manual { x: 0.2, y: 0.8 };
244 let t = zoom_at(&z, 150, 10);
245 assert!((t.center_x - 0.2).abs() < 1e-6);
246 assert!((t.center_y - 0.8).abs() < 1e-6);
247 }
248
249 #[test]
250 fn auto_falls_back_to_centre() {
251 let mut z = seg();
252 z.mode = ZoomMode::Auto;
253 let t = zoom_at(&z, 150, 10);
254 assert!((t.center_x - 0.5).abs() < 1e-9);
255 assert!((t.center_y - 0.5).abs() < 1e-9);
256 }
257
258 #[test]
259 fn default_ramp_is_about_point_six_seconds() {
260 assert_eq!(default_ramp_frames(30), 18);
261 assert_eq!(default_ramp_frames(60), 36);
262 assert_eq!(default_ramp_frames(1), 1); }
264
265 #[test]
266 fn active_zoom_picks_containing_segment() {
267 let mut p = EditProject::from_recording(ClipRef::new(
268 PathBuf::from("/tmp/a.mp4"),
269 1920,
270 1080,
271 30,
272 600,
273 ));
274 p.zooms = vec![ZoomSegment::manual(ZoomId(1), 100, 200, 2.0)];
275 assert!(active_zoom_at(&p, 50).is_identity());
276 assert!(!active_zoom_at(&p, 150).is_identity());
277 assert!((active_zoom_at(&p, 150).scale - 2.0).abs() < 1e-9);
279 }
280
281 #[test]
282 fn keyframes_bracket_the_hold() {
283 let kfs = zoom_keyframes(&seg(), 10); assert_eq!(kfs.len(), 4);
285 assert_eq!(kfs[0].frame, 100);
286 assert!((kfs[0].scale - 1.0).abs() < 1e-9);
287 assert_eq!(kfs[1].frame, 110);
288 assert!((kfs[1].scale - 2.0).abs() < 1e-9);
289 assert_eq!(kfs[2].frame, 190);
290 assert!((kfs[2].scale - 2.0).abs() < 1e-9);
291 assert_eq!(kfs[3].frame, 200);
292 assert!((kfs[3].scale - 1.0).abs() < 1e-9);
293 }
294
295 #[test]
296 fn sub_one_amount_never_zooms_out() {
297 let z = ZoomSegment::manual(ZoomId(4), 0, 100, 0.5);
301 for f in 0..100 {
302 assert!(
303 zoom_at(&z, f, 10).scale >= 1.0 - 1e-9,
304 "scale never below 1.0 at frame {f}"
305 );
306 }
307 for kf in zoom_keyframes(&z, 10) {
308 assert!(kf.scale >= 1.0 - 1e-9, "keyframe scale never below 1.0");
309 }
310 }
311
312 #[test]
313 fn keyframes_collapse_to_a_triangle_with_no_hold() {
314 let z = ZoomSegment::manual(ZoomId(3), 0, 10, 3.0);
316 let kfs = zoom_keyframes(&z, 100);
317 assert_eq!(kfs.len(), 3);
318 assert_eq!(kfs[1].frame, 5);
319 assert!((kfs[1].scale - 3.0).abs() < 1e-9);
320 }
321}