Skip to main content

playback/
editor_player.rs

1//! [`EditorPlayer`] — the editor's frame-indexed, variable-rate playback
2//! clock (ED.4 / M-EDIT).
3//!
4//! The recorder's [`Player`](crate::Player) is wall-clock paced and 1×
5//! only. The editor needs a clock that is the single authority over time:
6//! it must seek to an exact frame, step one frame at a time, play at a
7//! chosen rate, honour in/out points, and loop. Crucially it is built on
8//! [`wisp_animation::Driver`] — the *same* clock the zoom animation engine
9//! (ED.16) samples — so the playhead and the cinematic zoom Tracks advance
10//! in lockstep in both realtime preview and deterministic export.
11//!
12//! `EditorPlayer` is a pure clock: it computes `current_frame()` but does
13//! not own the decoder. The preview (ED.6) and export (ED.20) read
14//! `current_frame()` and pull that frame from
15//! [`decode::EditorVideoStream`](decode::editor_stream::EditorVideoStream).
16
17use std::time::Duration;
18
19use wisp_animation::Driver;
20
21/// A frame-indexed playback clock over a fixed-length project.
22///
23/// Time is measured in **project frames** at [`fps`](Self::fps). The
24/// playhead is constrained to `[in_frame, out_frame)`; out of those, in/out
25/// points scope playback + export, and looping wraps back to `in_frame`.
26#[derive(Clone, Debug)]
27pub struct EditorPlayer {
28    driver: Driver,
29    fps: u32,
30    duration_frames: u64,
31    in_frame: u64,
32    /// Exclusive end of the playable range (defaults to `duration_frames`).
33    out_frame: u64,
34    looping: bool,
35}
36
37impl EditorPlayer {
38    /// A realtime player for a project of `duration_frames` at `fps`.
39    /// Starts paused at frame 0 with no in/out trim and looping off.
40    #[must_use]
41    pub fn new(fps: u32, duration_frames: u64) -> Self {
42        Self::with_driver(Driver::realtime(), fps, duration_frames)
43    }
44
45    /// A fixed-step player (one frame per [`tick`](Self::tick) at rate 1×)
46    /// for deterministic, reproducible stepping — the export clock.
47    #[must_use]
48    pub fn fixed(fps: u32, duration_frames: u64) -> Self {
49        let fps = fps.max(1);
50        let dt = Duration::from_secs_f64(1.0 / f64::from(fps));
51        Self::with_driver(Driver::fixed(dt), fps, duration_frames)
52    }
53
54    fn with_driver(driver: Driver, fps: u32, duration_frames: u64) -> Self {
55        let fps = fps.max(1);
56        Self {
57            driver,
58            fps,
59            duration_frames,
60            in_frame: 0,
61            out_frame: duration_frames,
62            looping: false,
63        }
64    }
65
66    // ── Transport ────────────────────────────────────────────────────
67
68    /// Begin advancing. If the playhead is parked at the end of a
69    /// non-looping range, restart from the in-point first.
70    pub fn play(&mut self) {
71        if !self.looping && self.current_frame() >= self.last_frame() {
72            self.driver.seek(self.frame_to_elapsed(self.in_frame));
73        }
74        self.driver.play();
75    }
76
77    /// Pause advancing.
78    pub fn pause(&mut self) {
79        self.driver.pause();
80    }
81
82    /// Toggle play/pause.
83    pub fn toggle_play(&mut self) {
84        if self.is_playing() {
85            self.pause();
86        } else {
87            self.play();
88        }
89    }
90
91    /// Whether the clock is currently advancing.
92    #[must_use]
93    pub const fn is_playing(&self) -> bool {
94        self.driver.is_playing()
95    }
96
97    /// Seek to an exact frame (clamped to the playable range).
98    pub fn seek(&mut self, frame: u64) {
99        let clamped = frame.clamp(self.in_frame, self.last_frame());
100        self.driver.seek(self.frame_to_elapsed(clamped));
101    }
102
103    /// Step `delta` frames (negative = back) and pause. Clamps at the
104    /// range boundaries.
105    pub fn step(&mut self, delta: i64) {
106        self.driver.pause();
107        self.seek(self.current_frame().saturating_add_signed(delta));
108    }
109
110    /// Set the playback rate (`1.0` = realtime, `2.0` = 2×, `0.5` = half).
111    /// Negative rates clamp to 0 (reverse playback is a future ticket).
112    pub fn set_rate(&mut self, rate: f32) {
113        self.driver.set_time_scale(rate);
114    }
115
116    /// Current playback rate.
117    #[must_use]
118    pub const fn rate(&self) -> f32 {
119        self.driver.time_scale()
120    }
121
122    /// Advance the clock by `dt` (realtime mode) or one fixed step (fixed
123    /// mode). At the end of the range: loop back to the in-point if
124    /// [`looping`](Self::looping), otherwise clamp to the last frame and
125    /// pause. No-op while paused.
126    pub fn tick(&mut self, dt: Duration) {
127        if !self.driver.is_playing() {
128            return;
129        }
130        self.driver.tick(dt);
131        if self.raw_frame() >= self.out_frame {
132            if self.looping {
133                self.driver.seek(self.frame_to_elapsed(self.in_frame));
134            } else {
135                self.driver.seek(self.frame_to_elapsed(self.last_frame()));
136                self.driver.pause();
137            }
138        }
139    }
140
141    // ── In / out + loop ──────────────────────────────────────────────
142
143    /// Set the in/out points (order-independent). The out-point is the
144    /// exclusive end; the range is clamped to the project and forced
145    /// non-empty. The playhead is re-clamped into the new range.
146    pub fn set_in_out(&mut self, a: u64, b: u64) {
147        let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
148        self.in_frame = lo.min(self.duration_frames.saturating_sub(1));
149        self.out_frame = hi
150            .max(self.in_frame + 1)
151            .min(self.duration_frames.max(self.in_frame + 1));
152        let reclamped = self.current_frame();
153        self.driver.seek(self.frame_to_elapsed(reclamped));
154    }
155
156    /// Clear in/out points back to the full project range.
157    pub fn clear_in_out(&mut self) {
158        self.in_frame = 0;
159        self.out_frame = self.duration_frames;
160    }
161
162    /// Update the project length after an edit changed it (split / ripple
163    /// delete / undo). If the range was untrimmed (out at the old end) the
164    /// out-point tracks the new length; otherwise it's clamped. The
165    /// playhead is re-clamped into the new range.
166    pub fn set_duration(&mut self, duration_frames: u64) {
167        let was_full = self.out_frame >= self.duration_frames;
168        self.duration_frames = duration_frames;
169        self.in_frame = self.in_frame.min(duration_frames.saturating_sub(1));
170        self.out_frame = if was_full {
171            duration_frames
172        } else {
173            self.out_frame
174                .clamp(self.in_frame + 1, duration_frames.max(self.in_frame + 1))
175        };
176        let clamped = self.current_frame();
177        self.driver.seek(self.frame_to_elapsed(clamped));
178    }
179
180    /// In-point (inclusive).
181    #[must_use]
182    pub const fn in_frame(&self) -> u64 {
183        self.in_frame
184    }
185
186    /// Out-point (exclusive).
187    #[must_use]
188    pub const fn out_frame(&self) -> u64 {
189        self.out_frame
190    }
191
192    /// Enable / disable looping over the in/out range.
193    pub const fn set_looping(&mut self, looping: bool) {
194        self.looping = looping;
195    }
196
197    /// Whether looping is enabled.
198    #[must_use]
199    pub const fn looping(&self) -> bool {
200        self.looping
201    }
202
203    // ── Queries ──────────────────────────────────────────────────────
204
205    /// The current playhead frame (clamped to the playable range).
206    #[must_use]
207    pub fn current_frame(&self) -> u64 {
208        self.raw_frame().clamp(self.in_frame, self.last_frame())
209    }
210
211    /// Project frame rate.
212    #[must_use]
213    pub const fn fps(&self) -> u32 {
214        self.fps
215    }
216
217    /// Total project length in frames.
218    #[must_use]
219    pub const fn duration_frames(&self) -> u64 {
220        self.duration_frames
221    }
222
223    /// Borrow the underlying [`Driver`] — e.g. for the zoom engine (ED.16)
224    /// to sample animation Tracks against the same clock as the playhead.
225    #[must_use]
226    pub const fn driver(&self) -> &Driver {
227        &self.driver
228    }
229
230    /// Normalised progress through the in/out range, `0.0..=1.0`.
231    #[must_use]
232    pub fn progress(&self) -> f32 {
233        let span = self.out_frame.saturating_sub(self.in_frame);
234        if span == 0 {
235            return 0.0;
236        }
237        let into = self.current_frame().saturating_sub(self.in_frame);
238        #[allow(
239            clippy::cast_precision_loss,
240            reason = "frame counts are well under 2^24; f32 is plenty for a 0..1 scrubber fraction"
241        )]
242        let p = into as f32 / span as f32;
243        p.clamp(0.0, 1.0)
244    }
245
246    // ── Internals ────────────────────────────────────────────────────
247
248    /// Last playable frame index (`out_frame - 1`, never below `in_frame`).
249    fn last_frame(&self) -> u64 {
250        self.out_frame.saturating_sub(1).max(self.in_frame)
251    }
252
253    /// Frame implied by the driver's elapsed time, before range clamping.
254    fn raw_frame(&self) -> u64 {
255        // +epsilon so a seek to k/fps that floats to k - 1e-12 still floors
256        // to k rather than k-1.
257        let raw = self.driver.elapsed().as_secs_f64() * f64::from(self.fps) + 1e-6;
258        if raw <= 0.0 {
259            return 0;
260        }
261        #[allow(
262            clippy::cast_possible_truncation,
263            clippy::cast_sign_loss,
264            reason = "raw is positive and finite (elapsed * fps); frame counts fit u64"
265        )]
266        let frame = raw.floor() as u64;
267        frame
268    }
269
270    fn frame_to_elapsed(&self, frame: u64) -> Duration {
271        #[allow(
272            clippy::cast_precision_loss,
273            reason = "frame counts are well under 2^52; u64→f64 is lossless at these magnitudes"
274        )]
275        let secs = frame as f64 / f64::from(self.fps);
276        Duration::from_secs_f64(secs.max(0.0))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    const FPS: u32 = 30;
285
286    #[test]
287    fn starts_paused_at_zero() {
288        let p = EditorPlayer::new(FPS, 1000);
289        assert!(!p.is_playing());
290        assert_eq!(p.current_frame(), 0);
291        assert!((p.rate() - 1.0).abs() < 1e-6);
292    }
293
294    #[test]
295    fn play_and_tick_advances_by_dt_times_fps() {
296        let mut p = EditorPlayer::new(FPS, 1000);
297        p.play();
298        p.tick(Duration::from_secs(1)); // 1 s @ 30 fps = 30 frames
299        assert_eq!(p.current_frame(), 30);
300    }
301
302    #[test]
303    fn rate_scales_advance() {
304        let mut p = EditorPlayer::new(FPS, 1000);
305        p.set_rate(2.0);
306        p.play();
307        p.tick(Duration::from_secs(1)); // 2× → 60 frames
308        assert_eq!(p.current_frame(), 60);
309
310        let mut half = EditorPlayer::new(FPS, 1000);
311        half.set_rate(0.5);
312        half.play();
313        half.tick(Duration::from_secs(1)); // 0.5× → 15 frames
314        assert_eq!(half.current_frame(), 15);
315    }
316
317    #[test]
318    fn pause_holds_the_playhead() {
319        let mut p = EditorPlayer::new(FPS, 1000);
320        p.play();
321        p.tick(Duration::from_secs(1));
322        p.pause();
323        p.tick(Duration::from_secs(5));
324        assert_eq!(p.current_frame(), 30);
325    }
326
327    #[test]
328    fn seek_is_exact() {
329        let mut p = EditorPlayer::new(FPS, 1000);
330        for target in [0, 1, 7, 99, 100, 333, 999] {
331            p.seek(target);
332            assert_eq!(p.current_frame(), target, "seek({target})");
333        }
334    }
335
336    #[test]
337    fn step_moves_one_frame_and_pauses() {
338        let mut p = EditorPlayer::new(FPS, 1000);
339        p.play();
340        p.seek(100);
341        p.step(1);
342        assert_eq!(p.current_frame(), 101);
343        assert!(!p.is_playing(), "stepping pauses");
344        p.step(-1);
345        assert_eq!(p.current_frame(), 100);
346        p.step(-1000); // clamps at 0
347        assert_eq!(p.current_frame(), 0);
348    }
349
350    #[test]
351    fn in_out_clamps_the_playhead() {
352        let mut p = EditorPlayer::new(FPS, 1000);
353        p.set_in_out(10, 20); // playable [10, 20), last = 19
354        assert_eq!(p.in_frame(), 10);
355        assert_eq!(p.out_frame(), 20);
356        p.seek(5);
357        assert_eq!(p.current_frame(), 10, "below in clamps to in");
358        p.seek(50);
359        assert_eq!(p.current_frame(), 19, "above out clamps to last");
360        // Order-independent.
361        p.set_in_out(40, 30);
362        assert_eq!(p.in_frame(), 30);
363        assert_eq!(p.out_frame(), 40);
364    }
365
366    #[test]
367    fn not_looping_clamps_and_pauses_at_end() {
368        let mut p = EditorPlayer::new(FPS, 10); // 10 frames, out = 10
369        p.play();
370        p.tick(Duration::from_secs(1)); // would reach frame 30 → past end
371        assert_eq!(p.current_frame(), 9, "clamped to last frame");
372        assert!(!p.is_playing(), "paused at end");
373    }
374
375    #[test]
376    fn looping_wraps_to_in_point() {
377        let mut p = EditorPlayer::new(FPS, 1000);
378        p.set_in_out(0, 10);
379        p.set_looping(true);
380        p.play();
381        p.tick(Duration::from_secs(1)); // past out → wrap
382        assert!(p.is_playing(), "loop keeps playing");
383        assert!(
384            p.current_frame() < 10,
385            "wrapped back into [0,10), got {}",
386            p.current_frame()
387        );
388    }
389
390    #[test]
391    fn play_from_end_restarts() {
392        let mut p = EditorPlayer::new(FPS, 100);
393        p.seek(99); // last frame
394        p.play();
395        // Restarted from the in-point rather than staying parked at the end.
396        assert_eq!(p.current_frame(), 0);
397        assert!(p.is_playing());
398    }
399
400    #[test]
401    fn fixed_driver_steps_one_frame_per_tick() {
402        let mut p = EditorPlayer::fixed(FPS, 100);
403        p.play();
404        for expected in 1..=5 {
405            p.tick(Duration::ZERO); // fixed mode ignores the dt
406            assert_eq!(p.current_frame(), expected);
407        }
408    }
409
410    #[test]
411    fn progress_spans_in_out() {
412        let mut p = EditorPlayer::new(FPS, 100);
413        p.set_in_out(0, 100);
414        p.seek(0);
415        assert!(p.progress() < 1e-6);
416        p.seek(50);
417        assert!((p.progress() - 0.5).abs() < 0.02, "got {}", p.progress());
418        p.seek(99);
419        assert!(p.progress() > 0.95);
420    }
421
422    #[test]
423    fn set_duration_tracks_full_range_and_clamps_playhead() {
424        let mut p = EditorPlayer::new(FPS, 900);
425        p.seek(800);
426        // Shrink (e.g. ripple delete): playhead clamps, untrimmed out tracks.
427        p.set_duration(500);
428        assert_eq!(p.duration_frames(), 500);
429        assert_eq!(p.out_frame(), 500);
430        assert_eq!(p.current_frame(), 499);
431        // Grow (e.g. undo): the full range tracks back up.
432        p.set_duration(900);
433        assert_eq!(p.out_frame(), 900);
434        // With an explicit trim, set_duration clamps the out-point.
435        p.set_in_out(10, 400);
436        p.set_duration(200);
437        assert!(p.out_frame() <= 200 && p.out_frame() > p.in_frame());
438    }
439}