Skip to main content

edit/
project.rs

1//! [`EditProject`] — the top-level, serializable edit document and the
2//! project↔source frame mapping built on top of the segment list.
3
4use 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
12/// On-disk schema version. Bumped when the project file format changes
13/// incompatibly so older files can be migrated (ED.23).
14pub const SCHEMA_VERSION: u32 = 1;
15
16/// Default project timeline frame rate. The editor's time authority runs
17/// at this rate regardless of the source recording's frame rate.
18pub 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/// The serialized source of truth for one editing session.
29///
30/// The edited video is the ordered concatenation of [`segments`]; the
31/// cinematic framing comes from [`background`] / [`cursor`] / [`crop`] /
32/// [`aspect`]; cinematic punch-ins come from [`zooms`]. Nothing here is
33/// a GPU or media handle — the renderer and encoder re-derive every
34/// frame from this model at preview + export time.
35///
36/// [`segments`]: Self::segments
37/// [`background`]: Self::background
38/// [`cursor`]: Self::cursor
39/// [`crop`]: Self::crop
40/// [`aspect`]: Self::aspect
41/// [`zooms`]: Self::zooms
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
43pub struct EditProject {
44    /// On-disk schema version (see [`SCHEMA_VERSION`]).
45    #[serde(default = "default_schema_version")]
46    pub schema_version: u32,
47    /// The source recording this project edits.
48    pub source: ClipRef,
49    /// Ordered timeline slices (trim / split / speed).
50    pub segments: Vec<TimelineSegment>,
51    /// Cinematic zoom regions.
52    #[serde(default)]
53    pub zooms: Vec<ZoomSegment>,
54    /// Background framing (wallpaper / padding / radius / shadow).
55    #[serde(default)]
56    pub background: BackgroundConfig,
57    /// Cursor styling + auto-zoom detection settings.
58    #[serde(default)]
59    pub cursor: CursorConfig,
60    /// Optional crop / reframe of the source. `None` = full frame.
61    #[serde(default)]
62    pub crop: Option<CropRect>,
63    /// Output aspect ratio.
64    #[serde(default)]
65    pub aspect: AspectRatio,
66    /// Timeline frame rate (the editor's time authority).
67    #[serde(default = "default_project_fps")]
68    pub project_fps: u32,
69    /// Monotonic counter for allocating fresh [`crate::ZoomId`]s
70    /// (managed by edit operations in ED.2).
71    #[serde(default)]
72    pub next_zoom_id: u32,
73    /// Per-frame cursor track captured at record time (ED.17). `None` for
74    /// projects recorded before cursor capture, or when the macOS
75    /// input-monitoring permission was denied. Drives the cursor overlay
76    /// (ED.19).
77    #[serde(default)]
78    pub cursor_track: Option<Vec<CursorSample>>,
79    /// Click log captured at record time. Feeds auto-zoom detection
80    /// ([`crate::telemetry::auto_zoom_segments`]) and the cursor click-ripple
81    /// overlay (ED.19). `None` = no telemetry captured.
82    #[serde(default)]
83    pub clicks: Option<Vec<ClickEvent>>,
84}
85
86impl EditProject {
87    /// Build a fresh project from a source recording: a single,
88    /// full-length, real-time segment with default framing and no zooms —
89    /// i.e. "the recording, untouched", ready to edit.
90    #[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    /// Total length of the edited timeline in **project** frames.
110    #[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    /// Walk the segments with their cumulative **project-frame** start
120    /// offset: yields `(index, project_start, segment)`.
121    ///
122    /// This is the single accumulation site for the segment→project-time
123    /// walk — [`locate`](Self::locate), [`segment_project_range`](Self::segment_project_range),
124    /// and the UI's timeline lanes all derive from it instead of
125    /// re-implementing the running sum.
126    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    /// The `[start, end)` **project-frame** range occupied by segment
136    /// `index`, or `None` if the index is out of range.
137    #[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    /// Locate a project frame: returns `(segment index, source frame)`,
145    /// or `None` if the frame is at/past the end of the timeline.
146    ///
147    /// This is the core of the editor's time model — it walks the segment
148    /// list accumulating project-frame lengths until it finds the segment
149    /// containing `project_frame`, then maps the within-segment offset to
150    /// a source frame via that segment's `timescale`.
151    #[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    /// Map a project frame to the **source** frame the renderer should
159    /// decode, or `None` past the end of the timeline.
160    #[must_use]
161    pub fn source_time(&self, project_frame: Frame) -> Option<Frame> {
162        self.locate(project_frame).map(|(_, frame)| frame)
163    }
164
165    /// The composed-output **canvas** dimensions in pixels — the source's
166    /// longer edge reframed to the project's [`aspect`](Self::aspect) ratio
167    /// (e.g. a 1920×1080 recording → 1080×1920 when `aspect` is `Vertical`).
168    /// Both edges are even (H.264 chroma subsampling needs it). The source
169    /// frame is letterboxed/pillarboxed into this canvas at render time, so
170    /// changing the aspect reframes the export without distorting the content.
171    ///
172    /// This is the **unclamped** canvas (the live preview composes at it
173    /// directly); the export path additionally passes it through
174    /// `media::encode::fit_within_encoder_limits` for the HW-encoder edge cap.
175    #[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    /// The normalized `(x, y)` a cursor-targeted zoom should punch into at
182    /// project `frame` — the cursor's position there from
183    /// [`cursor_track`](Self::cursor_track), or the frame centre `(0.5, 0.5)`
184    /// when no track was captured. Pure; the basis for the editor's
185    /// "zoom to cursor" authoring action.
186    #[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    /// Generate auto-zoom blocks from the captured click log (ED.17 — the
195    /// "Auto-Zoom: detect from cursor" feature). Returns the number generated.
196    ///
197    /// A no-op (returns `0`) unless detection is enabled
198    /// ([`CursorConfig::auto_zoom`](crate::style::CursorConfig::auto_zoom)), a
199    /// click log is present, **and no zooms exist yet** — so a freshly recorded
200    /// clip arrives already punched-in on its click clusters, while a re-opened
201    /// edit keeps the zooms the user already tuned (the generator never
202    /// clobbers an existing list). The blocks are ordinary editable
203    /// [`ZoomSegment`]s ([`crate::telemetry::auto_zoom_segments`]) — the user
204    /// nudges, deletes, or retunes them like any other zoom.
205    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            // Keep the id allocator ahead of the generated blocks so a later
217            // manual zoom can't collide with an auto one.
218            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        // 1920×1080 source (long edge 1920).
254        let mut proj = EditProject::from_recording(sample_clip());
255        // Wide (default) keeps the source shape.
256        assert_eq!(proj.canvas_dims(), (1920, 1080));
257        // Vertical swaps to a 9:16 canvas seeded by the same long edge.
258        proj.aspect = AspectRatio::Vertical;
259        assert_eq!(proj.canvas_dims(), (1080, 1920));
260        // Square → a 1:1 canvas; Classic → 4:3. Both even.
261        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        // A portrait source (1080×1920, long edge 1920) exported Wide → a
272        // 16:9 canvas the portrait content pillarboxes into.
273        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        // No click log → nothing generated.
289        assert_eq!(proj.generate_auto_zooms(), 0);
290        assert!(proj.zooms.is_empty());
291
292        // A click → one auto-zoom block (detect_from_cursor defaults on).
293        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        // Idempotent: never clobbers an existing (e.g. user-tuned) zoom list.
303        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        // No track → centre.
312        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        // With a track → the cursor position at that frame (smoothing 0 = raw).
318        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        // seg0: src[0,300) real-time   → 300 project frames (project 0..300)
342        // seg1: src[300,600) at 2×     → 150 project frames (project 300..450)
343        // seg2: src[600,900) real-time → 300 project frames (project 450..750)
344        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        // First project frame of the 2× segment maps to its source start.
354        assert_eq!(proj.source_time(300), Some(300));
355        // Mid 2× segment: project offset 75 → source 300 + 150 = 450.
356        assert_eq!(proj.source_time(375), Some(450));
357        // First project frame of the final real-time segment.
358        assert_eq!(proj.source_time(450), Some(600));
359        assert_eq!(proj.source_time(749), Some(899));
360        // Past the end of the timeline.
361        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        // [0,300) real → project [0,300); [300,600) 2× → project [300,450);
368        // [600,900) real → project [450,750).
369        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        // ED.17/ED.19 telemetry round-trips losslessly too.
414        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        // A minimal project file (only required fields) should fill the
428        // rest from defaults — the forward-compat path for ED.23.
429        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        // A pre-ED.17 project (no telemetry fields) deserializes with the
442        // track/clicks absent — forward-compat for cursor capture.
443        assert!(proj.cursor_track.is_none());
444        assert!(proj.clicks.is_none());
445    }
446
447    #[test]
448    fn from_recording_has_no_telemetry() {
449        // A fresh project carries no cursor track or click log until capture
450        // (ED.17) attaches one at Record→Edit import.
451        let proj = EditProject::from_recording(sample_clip());
452        assert!(proj.cursor_track.is_none());
453        assert!(proj.clicks.is_none());
454    }
455}