1use serde::{Deserialize, Serialize};
5
6use crate::clip::ClipRef;
7use crate::segment::{Frame, TimelineSegment};
8use crate::style::{AspectRatio, BackgroundConfig, CropRect, CursorConfig};
9use crate::telemetry::{ClickEvent, CursorSample};
10use crate::zoom::ZoomSegment;
11
12pub const SCHEMA_VERSION: u32 = 1;
15
16pub const DEFAULT_PROJECT_FPS: u32 = 30;
19
20fn default_schema_version() -> u32 {
21 SCHEMA_VERSION
22}
23
24fn default_project_fps() -> u32 {
25 DEFAULT_PROJECT_FPS
26}
27
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
43pub struct EditProject {
44 #[serde(default = "default_schema_version")]
46 pub schema_version: u32,
47 pub source: ClipRef,
49 pub segments: Vec<TimelineSegment>,
51 #[serde(default)]
53 pub zooms: Vec<ZoomSegment>,
54 #[serde(default)]
56 pub background: BackgroundConfig,
57 #[serde(default)]
59 pub cursor: CursorConfig,
60 #[serde(default)]
62 pub crop: Option<CropRect>,
63 #[serde(default)]
65 pub aspect: AspectRatio,
66 #[serde(default = "default_project_fps")]
68 pub project_fps: u32,
69 #[serde(default)]
72 pub next_zoom_id: u32,
73 #[serde(default)]
78 pub cursor_track: Option<Vec<CursorSample>>,
79 #[serde(default)]
83 pub clicks: Option<Vec<ClickEvent>>,
84}
85
86impl EditProject {
87 #[must_use]
91 pub fn from_recording(source: ClipRef) -> Self {
92 let segments = vec![TimelineSegment::new(0, source.frame_count)];
93 Self {
94 schema_version: SCHEMA_VERSION,
95 source,
96 segments,
97 zooms: Vec::new(),
98 background: BackgroundConfig::default(),
99 cursor: CursorConfig::default(),
100 crop: None,
101 aspect: AspectRatio::default(),
102 project_fps: DEFAULT_PROJECT_FPS,
103 next_zoom_id: 0,
104 cursor_track: None,
105 clicks: None,
106 }
107 }
108
109 #[must_use]
111 pub fn project_duration(&self) -> Frame {
112 self.segments
113 .iter()
114 .copied()
115 .map(TimelineSegment::project_len)
116 .sum()
117 }
118
119 pub fn segment_offsets(&self) -> impl Iterator<Item = (usize, Frame, &TimelineSegment)> + '_ {
127 let mut acc: Frame = 0;
128 self.segments.iter().enumerate().map(move |(index, seg)| {
129 let start = acc;
130 acc += seg.project_len();
131 (index, start, seg)
132 })
133 }
134
135 #[must_use]
138 pub fn segment_project_range(&self, index: usize) -> Option<(Frame, Frame)> {
139 self.segment_offsets()
140 .nth(index)
141 .map(|(_, start, seg)| (start, start + seg.project_len()))
142 }
143
144 #[must_use]
152 pub fn locate(&self, project_frame: Frame) -> Option<(usize, Frame)> {
153 self.segment_offsets()
154 .find(|(_, start, seg)| project_frame < start + seg.project_len())
155 .map(|(index, start, seg)| (index, seg.source_frame_at(project_frame - start)))
156 }
157
158 #[must_use]
161 pub fn source_time(&self, project_frame: Frame) -> Option<Frame> {
162 self.locate(project_frame).map(|(_, frame)| frame)
163 }
164
165 #[must_use]
176 pub fn canvas_dims(&self) -> (u32, u32) {
177 let long = self.source.width.max(self.source.height).max(2);
178 self.aspect.canvas_dims(long)
179 }
180
181 #[must_use]
187 pub fn zoom_cursor_target(&self, frame: Frame) -> (f32, f32) {
188 self.cursor_track
189 .as_deref()
190 .and_then(|track| crate::telemetry::cursor_at(track, frame, 0))
191 .unwrap_or((0.5, 0.5))
192 }
193
194 pub fn generate_auto_zooms(&mut self) -> usize {
206 if !self.zooms.is_empty() {
207 return 0;
208 }
209 let Some(clicks) = self.clicks.as_deref() else {
210 return 0;
211 };
212 let zooms =
213 crate::telemetry::auto_zoom_segments(clicks, self.project_fps, &self.cursor.auto_zoom);
214 let n = zooms.len();
215 if n > 0 {
216 self.next_zoom_id = self.next_zoom_id.max(u32::try_from(n).unwrap_or(u32::MAX));
219 self.zooms = zooms;
220 }
221 n
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::style::CropRect;
229 use crate::telemetry::{ClickEvent, CursorSample};
230 use crate::zoom::{ZoomId, ZoomSegment};
231
232 fn sample_clip() -> ClipRef {
233 ClipRef::new(PathBuf::from("/tmp/rec.mp4"), 1920, 1080, 30, 900)
234 }
235
236 use std::path::PathBuf;
237
238 #[test]
239 fn from_recording_is_single_full_length_realtime_segment() {
240 let proj = EditProject::from_recording(sample_clip());
241 assert_eq!(proj.segments.len(), 1);
242 assert_eq!(proj.segments[0], TimelineSegment::new(0, 900));
243 assert_eq!(proj.project_duration(), 900);
244 assert!(proj.zooms.is_empty());
245 assert!(proj.crop.is_none());
246 assert_eq!(proj.project_fps, DEFAULT_PROJECT_FPS);
247 assert_eq!(proj.schema_version, SCHEMA_VERSION);
248 assert_eq!(proj.aspect, AspectRatio::Wide);
249 }
250
251 #[test]
252 fn canvas_dims_reframes_the_source_to_the_aspect() {
253 let mut proj = EditProject::from_recording(sample_clip());
255 assert_eq!(proj.canvas_dims(), (1920, 1080));
257 proj.aspect = AspectRatio::Vertical;
259 assert_eq!(proj.canvas_dims(), (1080, 1920));
260 proj.aspect = AspectRatio::Square;
262 assert_eq!(proj.canvas_dims(), (1920, 1920));
263 proj.aspect = AspectRatio::Classic;
264 let (w, h) = proj.canvas_dims();
265 assert_eq!((w, h), (1920, 1440));
266 assert_eq!((w & 1, h & 1), (0, 0), "canvas dims are even");
267 }
268
269 #[test]
270 fn canvas_dims_seeds_from_the_longer_source_edge_for_portrait_sources() {
271 let mut proj = EditProject::from_recording(ClipRef::new(
274 PathBuf::from("/tmp/p.mp4"),
275 1080,
276 1920,
277 30,
278 60,
279 ));
280 assert_eq!(proj.canvas_dims(), (1920, 1080));
281 proj.aspect = AspectRatio::Vertical;
282 assert_eq!(proj.canvas_dims(), (1080, 1920), "stays full vertical");
283 }
284
285 #[test]
286 fn generate_auto_zooms_punches_in_on_clicks() {
287 let mut proj = EditProject::from_recording(sample_clip());
288 assert_eq!(proj.generate_auto_zooms(), 0);
290 assert!(proj.zooms.is_empty());
291
292 proj.clicks = Some(vec![ClickEvent::new(100, 0.4, 0.6)]);
294 let n = proj.generate_auto_zooms();
295 assert_eq!(n, 1, "one click cluster → one zoom");
296 assert_eq!(proj.zooms.len(), 1);
297 assert!(
298 proj.next_zoom_id >= 1,
299 "id allocator advanced past the auto block"
300 );
301
302 proj.clicks = Some(vec![ClickEvent::new(500, 0.2, 0.2)]);
304 assert_eq!(proj.generate_auto_zooms(), 0, "guarded by non-empty zooms");
305 assert_eq!(proj.zooms.len(), 1);
306 }
307
308 #[test]
309 fn zoom_cursor_target_uses_the_track_or_falls_back_to_centre() {
310 let mut proj = EditProject::from_recording(sample_clip());
311 let (cx, cy) = proj.zoom_cursor_target(100);
313 assert!(
314 (cx - 0.5).abs() < 1e-6 && (cy - 0.5).abs() < 1e-6,
315 "no track → centre"
316 );
317 proj.cursor_track = Some(vec![
319 CursorSample::new(0, 0.1, 0.1),
320 CursorSample::new(100, 0.8, 0.3),
321 ]);
322 let (tx, ty) = proj.zoom_cursor_target(100);
323 assert!(
324 (tx - 0.8).abs() < 1e-6 && (ty - 0.3).abs() < 1e-6,
325 "track → cursor at frame"
326 );
327 }
328
329 #[test]
330 fn generate_auto_zooms_respects_the_detect_toggle() {
331 let mut proj = EditProject::from_recording(sample_clip());
332 proj.cursor.auto_zoom.detect_from_cursor = false;
333 proj.clicks = Some(vec![ClickEvent::new(100, 0.5, 0.5)]);
334 assert_eq!(proj.generate_auto_zooms(), 0, "detection off → no zooms");
335 assert!(proj.zooms.is_empty());
336 }
337
338 #[test]
339 fn source_time_walks_segments_including_a_speed_segment() {
340 let mut proj = EditProject::from_recording(sample_clip());
341 proj.segments = vec![
345 TimelineSegment::new(0, 300),
346 TimelineSegment::with_speed(300, 600, 2.0),
347 TimelineSegment::new(600, 900),
348 ];
349 assert_eq!(proj.project_duration(), 300 + 150 + 300);
350
351 assert_eq!(proj.source_time(0), Some(0));
352 assert_eq!(proj.source_time(299), Some(299));
353 assert_eq!(proj.source_time(300), Some(300));
355 assert_eq!(proj.source_time(375), Some(450));
357 assert_eq!(proj.source_time(450), Some(600));
359 assert_eq!(proj.source_time(749), Some(899));
360 assert_eq!(proj.source_time(750), None);
362 }
363
364 #[test]
365 fn segment_offsets_and_ranges_are_the_canonical_walk() {
366 let mut proj = EditProject::from_recording(sample_clip());
367 proj.segments = vec![
370 TimelineSegment::new(0, 300),
371 TimelineSegment::with_speed(300, 600, 2.0),
372 TimelineSegment::new(600, 900),
373 ];
374 let offsets: Vec<_> = proj
375 .segment_offsets()
376 .map(|(i, start, seg)| (i, start, seg.project_len()))
377 .collect();
378 assert_eq!(offsets, vec![(0, 0, 300), (1, 300, 150), (2, 450, 300)]);
379 assert_eq!(proj.segment_project_range(0), Some((0, 300)));
380 assert_eq!(proj.segment_project_range(1), Some((300, 450)));
381 assert_eq!(proj.segment_project_range(2), Some((450, 750)));
382 assert_eq!(proj.segment_project_range(3), None);
383 }
384
385 #[test]
386 fn locate_returns_segment_index() {
387 let mut proj = EditProject::from_recording(sample_clip());
388 proj.segments = vec![TimelineSegment::new(0, 300), TimelineSegment::new(300, 900)];
389 assert_eq!(proj.locate(0), Some((0, 0)));
390 assert_eq!(proj.locate(299), Some((0, 299)));
391 assert_eq!(proj.locate(300), Some((1, 300)));
392 assert_eq!(proj.locate(899), Some((1, 899)));
393 assert_eq!(proj.locate(900), None);
394 }
395
396 #[test]
397 fn serde_round_trip_is_lossless() {
398 let mut proj = EditProject::from_recording(sample_clip());
399 proj.segments = vec![
400 TimelineSegment::new(0, 300),
401 TimelineSegment::with_speed(300, 600, 2.0),
402 TimelineSegment::new(600, 900),
403 ];
404 proj.zooms = vec![ZoomSegment::manual(ZoomId(0), 30, 90, 1.6)];
405 proj.next_zoom_id = 1;
406 proj.crop = Some(CropRect {
407 x: 0.1,
408 y: 0.0,
409 width: 0.8,
410 height: 1.0,
411 });
412 proj.aspect = AspectRatio::Vertical;
413 proj.cursor_track = Some(vec![
415 CursorSample::new(0, 0.1, 0.2),
416 CursorSample::new(15, 0.5, 0.5),
417 ]);
418 proj.clicks = Some(vec![ClickEvent::new(15, 0.5, 0.5)]);
419
420 let json = serde_json::to_string_pretty(&proj).expect("serialize");
421 let back: EditProject = serde_json::from_str(&json).expect("deserialize");
422 assert_eq!(proj, back);
423 }
424
425 #[test]
426 fn missing_optional_fields_deserialize_to_defaults() {
427 let json = r#"{
430 "source": { "path": "/tmp/x.mp4", "width": 1280, "height": 720, "source_fps": 30, "frame_count": 600 },
431 "segments": [ { "source_start": 0, "source_end": 600, "timescale": 1.0 } ]
432 }"#;
433 let proj: EditProject = serde_json::from_str(json).expect("deserialize minimal");
434 assert_eq!(proj.schema_version, SCHEMA_VERSION);
435 assert_eq!(proj.project_fps, DEFAULT_PROJECT_FPS);
436 assert_eq!(proj.aspect, AspectRatio::Wide);
437 assert!(proj.zooms.is_empty());
438 assert!(proj.crop.is_none());
439 assert_eq!(proj.background, BackgroundConfig::default());
440 assert_eq!(proj.project_duration(), 600);
441 assert!(proj.cursor_track.is_none());
444 assert!(proj.clicks.is_none());
445 }
446
447 #[test]
448 fn from_recording_has_no_telemetry() {
449 let proj = EditProject::from_recording(sample_clip());
452 assert!(proj.cursor_track.is_none());
453 assert!(proj.clicks.is_none());
454 }
455}