1use serde::{Deserialize, Serialize};
5
6pub type Frame = u64;
11
12#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
22pub struct TimelineSegment {
23 pub source_start: Frame,
25 pub source_end: Frame,
28 pub timescale: f64,
33}
34
35impl TimelineSegment {
36 #[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 #[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 #[must_use]
60 pub fn source_len(self) -> Frame {
61 self.source_end.saturating_sub(self.source_start)
62 }
63
64 #[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 #[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 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#[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#[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 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 assert_eq!(seg.source_frame_at(0), 0);
145 assert_eq!(seg.source_frame_at(25), 50);
146 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 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}