Skip to main content

edit/
segment.rs

1//! Timeline segments — the ordered list that encodes trim, split, and
2//! speed, plus the project↔source frame arithmetic.
3
4use serde::{Deserialize, Serialize};
5
6/// A zero-based frame index. Source frames (positions in the original
7/// recording) and project frames (positions on the edited timeline) are
8/// both `Frame`; field names and docs disambiguate which space a value
9/// lives in. Matches `decode::VideoFrame::frame_index`'s `u64`.
10pub type Frame = u64;
11
12/// One contiguous slice of the source recording, played at `timescale`.
13///
14/// The project's video is the ordered concatenation of its segments:
15///
16/// - **Trim** = adjust [`source_start`](Self::source_start) /
17///   [`source_end`](Self::source_end).
18/// - **Split** = replace one segment with two adjacent ones that share
19///   the cut frame.
20/// - **Speed** = change [`timescale`](Self::timescale).
21#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
22pub struct TimelineSegment {
23    /// First source frame of the slice (inclusive).
24    pub source_start: Frame,
25    /// One past the last source frame of the slice (exclusive), so
26    /// `source_end - source_start` is the slice length in source frames.
27    pub source_end: Frame,
28    /// Playback speed multiplier. `1.0` = real time; `2.0` = 2× (the
29    /// slice's source span is traversed in half the project time);
30    /// `0.5` = half speed (slow motion). Always `> 0`; non-positive
31    /// values are treated as `1.0` by the mapping helpers.
32    pub timescale: f64,
33}
34
35impl TimelineSegment {
36    /// A real-time (`timescale = 1.0`) slice of
37    /// `[source_start, source_end)`.
38    #[must_use]
39    pub fn new(source_start: Frame, source_end: Frame) -> Self {
40        Self {
41            source_start,
42            source_end,
43            timescale: 1.0,
44        }
45    }
46
47    /// A slice of `[source_start, source_end)` played at `timescale`.
48    #[must_use]
49    pub fn with_speed(source_start: Frame, source_end: Frame, timescale: f64) -> Self {
50        Self {
51            source_start,
52            source_end,
53            timescale,
54        }
55    }
56
57    /// Length of the slice in **source** frames
58    /// (`source_end - source_start`, saturating at 0).
59    #[must_use]
60    pub fn source_len(self) -> Frame {
61        self.source_end.saturating_sub(self.source_start)
62    }
63
64    /// Length of the slice in **project** frames after applying
65    /// `timescale`. A 100-source-frame slice at `timescale = 2.0`
66    /// occupies 50 project frames; at `0.5`, 200 project frames. Always
67    /// at least 1 project frame for a non-empty slice so a sped-up clip
68    /// never vanishes entirely.
69    #[must_use]
70    pub fn project_len(self) -> Frame {
71        let src = self.source_len();
72        if src == 0 {
73            return 0;
74        }
75        scale_div(src, self.norm_timescale()).max(1)
76    }
77
78    /// Map a project-frame offset *within this segment*
79    /// (`0..self.project_len()`) to the source frame the renderer should
80    /// decode. Clamped to the last source frame of the slice so it can
81    /// never index past [`source_end`](Self::source_end).
82    #[must_use]
83    pub fn source_frame_at(self, project_offset: Frame) -> Frame {
84        let raw = self.source_start + scale_mul(project_offset, self.norm_timescale());
85        raw.min(self.source_end.saturating_sub(1).max(self.source_start))
86    }
87
88    /// `timescale`, sanitized: non-finite or non-positive values fall
89    /// back to real time (`1.0`) so the mapping never divides by zero or
90    /// produces NaN.
91    fn norm_timescale(self) -> f64 {
92        if self.timescale.is_finite() && self.timescale > 0.0 {
93            self.timescale
94        } else {
95            1.0
96        }
97    }
98}
99
100/// `round(n / scale)` in frame space — the single contained cast site
101/// for the project↔source mapping.
102#[allow(
103    clippy::cast_precision_loss,
104    clippy::cast_possible_truncation,
105    clippy::cast_sign_loss,
106    reason = "frame counts are well under 2^52 so u64→f64 is lossless; the result is clamped non-negative and rounded before the f64→u64 cast"
107)]
108fn scale_div(n: Frame, scale: f64) -> Frame {
109    (n as f64 / scale).round().max(0.0) as Frame
110}
111
112/// `round(n * scale)` in frame space.
113#[allow(
114    clippy::cast_precision_loss,
115    clippy::cast_possible_truncation,
116    clippy::cast_sign_loss,
117    reason = "frame counts are well under 2^52 so u64→f64 is lossless; the result is clamped non-negative and rounded before the f64→u64 cast"
118)]
119fn scale_mul(n: Frame, scale: f64) -> Frame {
120    (n as f64 * scale).round().max(0.0) as Frame
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn real_time_segment_maps_one_to_one() {
129        let seg = TimelineSegment::new(100, 200);
130        assert_eq!(seg.source_len(), 100);
131        assert_eq!(seg.project_len(), 100);
132        assert_eq!(seg.source_frame_at(0), 100);
133        assert_eq!(seg.source_frame_at(50), 150);
134        // Last project frame maps inside the slice (never == source_end).
135        assert_eq!(seg.source_frame_at(99), 199);
136    }
137
138    #[test]
139    fn double_speed_halves_project_length() {
140        let seg = TimelineSegment::with_speed(0, 100, 2.0);
141        assert_eq!(seg.source_len(), 100);
142        assert_eq!(seg.project_len(), 50);
143        // project offset p → source 2p
144        assert_eq!(seg.source_frame_at(0), 0);
145        assert_eq!(seg.source_frame_at(25), 50);
146        // End of segment stays inside the slice.
147        assert!(seg.source_frame_at(49) < 100);
148    }
149
150    #[test]
151    fn half_speed_doubles_project_length() {
152        let seg = TimelineSegment::with_speed(0, 100, 0.5);
153        assert_eq!(seg.project_len(), 200);
154        assert_eq!(seg.source_frame_at(0), 0);
155        assert_eq!(seg.source_frame_at(100), 50);
156    }
157
158    #[test]
159    fn empty_slice_has_zero_project_length() {
160        let seg = TimelineSegment::new(42, 42);
161        assert_eq!(seg.source_len(), 0);
162        assert_eq!(seg.project_len(), 0);
163    }
164
165    #[test]
166    fn non_empty_sped_up_slice_never_vanishes() {
167        // 1 source frame at 100× would round to 0 without the .max(1).
168        let seg = TimelineSegment::with_speed(0, 1, 100.0);
169        assert_eq!(seg.project_len(), 1);
170    }
171
172    #[test]
173    fn invalid_timescale_falls_back_to_real_time() {
174        for bad in [0.0, -2.0, f64::NAN, f64::INFINITY] {
175            let seg = TimelineSegment::with_speed(0, 100, bad);
176            assert_eq!(seg.project_len(), 100, "bad timescale {bad} → real time");
177            assert_eq!(seg.source_frame_at(10), 10);
178        }
179    }
180
181    #[test]
182    fn source_frame_never_reaches_source_end() {
183        let seg = TimelineSegment::with_speed(10, 20, 0.3);
184        for p in 0..seg.project_len() {
185            let f = seg.source_frame_at(p);
186            assert!(f >= seg.source_start && f < seg.source_end, "p={p} → f={f}");
187        }
188    }
189}