summaryrefslogtreecommitdiff
path: root/rust/crates/playback/src
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 19:41:16 +0200
committercashmere <cashmere@autistici.org>2026-07-28 19:41:16 +0200
commitf5a82152394fde4d8f740f664ab7737b82861618 (patch)
tree5a51b181abead1ce03d2dbfc12b77918d3be0e1b /rust/crates/playback/src
parente837e7504379e100ff3b56282577ec67bb88bec9 (diff)
downloadopencut-f5a82152394fde4d8f740f664ab7737b82861618.tar.gz
feat: GPUI desktop port foundation
- scene crate: full project/scene/element model, serde round-trip proven against the web SerializedProject shape - transfer crate: .ocp zip container, cross-compat with web fflate archives proven in both directions - storage crate: filesystem project store replacing IndexedDB, .ocp import/export - media crate: ffmpeg-next 8 decoder (frames, audio, thumbnails, waveform summaries) - playback crate: wall-clock anchored, frame-rounded playback clock - gpu crate: native texture readback; compositor renders headless offscreen on Vulkan (spike test) - flake devshell: rust toolchain, GPUI native deps, ffmpeg dev, bindgen clang args; GPUI hello-world builds - port plan in notes/gpui-port.md
Diffstat (limited to 'rust/crates/playback/src')
-rw-r--r--rust/crates/playback/src/lib.rs316
1 files changed, 316 insertions, 0 deletions
diff --git a/rust/crates/playback/src/lib.rs b/rust/crates/playback/src/lib.rs
new file mode 100644
index 00000000..2e10e818
--- /dev/null
+++ b/rust/crates/playback/src/lib.rs
@@ -0,0 +1,316 @@
+use time::{FrameRate, MediaTime};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PlaybackEvent {
+ Started,
+ Paused,
+ Seeked(MediaTime),
+ Updated(MediaTime),
+ ReachedEnd(MediaTime),
+}
+
+pub struct PlaybackClock {
+ is_playing: bool,
+ current_time: MediaTime,
+ total_duration: MediaTime,
+ frame_rate: Option<FrameRate>,
+ anchor_media: MediaTime,
+ anchor_wall_seconds: f64,
+ volume: f64,
+ previous_volume: f64,
+ muted: bool,
+ is_scrubbing: bool,
+}
+
+impl Default for PlaybackClock {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl PlaybackClock {
+ pub fn new() -> Self {
+ Self {
+ is_playing: false,
+ current_time: MediaTime::ZERO,
+ total_duration: MediaTime::ZERO,
+ frame_rate: None,
+ anchor_media: MediaTime::ZERO,
+ anchor_wall_seconds: 0.0,
+ volume: 1.0,
+ previous_volume: 1.0,
+ muted: false,
+ is_scrubbing: false,
+ }
+ }
+
+ pub fn is_playing(&self) -> bool {
+ self.is_playing
+ }
+
+ pub fn current_time(&self) -> MediaTime {
+ self.current_time
+ }
+
+ pub fn total_duration(&self) -> MediaTime {
+ self.total_duration
+ }
+
+ pub fn volume(&self) -> f64 {
+ self.volume
+ }
+
+ pub fn is_muted(&self) -> bool {
+ self.muted
+ }
+
+ pub fn is_scrubbing(&self) -> bool {
+ self.is_scrubbing
+ }
+
+ pub fn set_total_duration(&mut self, duration: MediaTime) -> Vec<PlaybackEvent> {
+ self.total_duration = duration;
+ let clamped = self.clamp(self.current_time);
+ let reached_end = self.is_playing && clamped >= self.total_duration;
+ if clamped == self.current_time && !reached_end {
+ return Vec::new();
+ }
+ self.current_time = clamped;
+ let mut events = vec![PlaybackEvent::Seeked(clamped)];
+ if reached_end {
+ self.is_playing = false;
+ events.push(PlaybackEvent::Paused);
+ }
+ events
+ }
+
+ pub fn set_frame_rate(&mut self, frame_rate: Option<FrameRate>) {
+ self.frame_rate = frame_rate;
+ }
+
+ pub fn play(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ if self.total_duration <= MediaTime::ZERO {
+ return Vec::new();
+ }
+ let mut events = Vec::new();
+ if self.current_time >= self.total_duration {
+ self.current_time = MediaTime::ZERO;
+ events.push(PlaybackEvent::Seeked(MediaTime::ZERO));
+ }
+ self.is_playing = true;
+ self.anchor_media = self.current_time;
+ self.anchor_wall_seconds = wall_seconds;
+ events.push(PlaybackEvent::Started);
+ events
+ }
+
+ pub fn pause(&mut self) -> Vec<PlaybackEvent> {
+ if !self.is_playing {
+ return Vec::new();
+ }
+ self.is_playing = false;
+ vec![PlaybackEvent::Paused]
+ }
+
+ pub fn toggle(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ if self.is_playing {
+ self.pause()
+ } else {
+ self.play(wall_seconds)
+ }
+ }
+
+ pub fn seek(&mut self, time: MediaTime, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ self.current_time = self.clamp(time);
+ if self.is_playing {
+ self.anchor_media = self.current_time;
+ self.anchor_wall_seconds = wall_seconds;
+ }
+ vec![PlaybackEvent::Seeked(self.current_time)]
+ }
+
+ pub fn tick(&mut self, wall_seconds: f64) -> Vec<PlaybackEvent> {
+ if !self.is_playing {
+ return Vec::new();
+ }
+ let elapsed = wall_seconds - self.anchor_wall_seconds;
+ let Some(elapsed_time) = MediaTime::from_seconds_f64(elapsed) else {
+ return Vec::new();
+ };
+ let raw = MediaTime::from_ticks(
+ self.anchor_media.as_ticks().saturating_add(elapsed_time.as_ticks()),
+ );
+ let rounded = self.round_to_frame(raw);
+
+ if rounded >= self.total_duration {
+ self.is_playing = false;
+ self.current_time = self.total_duration;
+ return vec![
+ PlaybackEvent::Paused,
+ PlaybackEvent::ReachedEnd(self.total_duration),
+ ];
+ }
+ if rounded == self.current_time {
+ return Vec::new();
+ }
+ self.current_time = rounded;
+ vec![PlaybackEvent::Updated(rounded)]
+ }
+
+ pub fn set_volume(&mut self, volume: f64) {
+ let clamped = volume.clamp(0.0, 1.0);
+ self.volume = clamped;
+ self.muted = clamped == 0.0;
+ if clamped > 0.0 {
+ self.previous_volume = clamped;
+ }
+ }
+
+ pub fn mute(&mut self) {
+ if self.volume > 0.0 {
+ self.previous_volume = self.volume;
+ }
+ self.muted = true;
+ self.volume = 0.0;
+ }
+
+ pub fn unmute(&mut self) {
+ self.muted = false;
+ self.volume = self.previous_volume;
+ }
+
+ pub fn toggle_mute(&mut self) {
+ if self.muted {
+ self.unmute();
+ } else {
+ self.mute();
+ }
+ }
+
+ pub fn set_scrubbing(&mut self, is_scrubbing: bool) {
+ self.is_scrubbing = is_scrubbing;
+ }
+
+ fn clamp(&self, time: MediaTime) -> MediaTime {
+ time.clamp(MediaTime::ZERO, self.total_duration)
+ }
+
+ fn round_to_frame(&self, time: MediaTime) -> MediaTime {
+ let Some(rate) = self.frame_rate else {
+ return time;
+ };
+ let Some(frame) = time.to_frame_round(rate) else {
+ return time;
+ };
+ MediaTime::from_frame(frame, rate).unwrap_or(time)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const FPS_30: FrameRate = FrameRate {
+ numerator: 30,
+ denominator: 1,
+ };
+
+ fn clock() -> PlaybackClock {
+ let mut clock = PlaybackClock::new();
+ clock.set_frame_rate(Some(FPS_30));
+ clock.set_total_duration(MediaTime::from_seconds_f64(4.0).unwrap());
+ clock
+ }
+
+ #[test]
+ fn play_at_end_restarts_from_zero() {
+ let mut clock = clock();
+ clock.seek(MediaTime::from_seconds_f64(10.0).unwrap(), 0.0);
+ let events = clock.play(0.0);
+ assert!(events.contains(&PlaybackEvent::Seeked(MediaTime::ZERO)));
+ assert!(events.contains(&PlaybackEvent::Started));
+ }
+
+ #[test]
+ fn play_with_empty_timeline_does_nothing() {
+ let mut clock = PlaybackClock::new();
+ assert!(clock.play(0.0).is_empty());
+ assert!(!clock.is_playing());
+ }
+
+ #[test]
+ fn tick_rounds_to_frames() {
+ let mut clock = clock();
+ clock.play(0.0);
+ let events = clock.tick(0.5);
+ let expected = MediaTime::from_frame(15, FPS_30).unwrap();
+ assert_eq!(events, vec![PlaybackEvent::Updated(expected)]);
+ assert_eq!(clock.current_time(), expected);
+ }
+
+ #[test]
+ fn tick_stops_at_end() {
+ let mut clock = clock();
+ clock.play(0.0);
+ let events = clock.tick(10.0);
+ assert!(events.contains(&PlaybackEvent::Paused));
+ assert_eq!(clock.current_time(), clock.total_duration());
+ assert!(!clock.is_playing());
+ }
+
+ #[test]
+ fn tick_same_frame_emits_nothing() {
+ let mut clock = clock();
+ clock.play(0.0);
+ assert!(clock.tick(0.001).is_empty());
+ }
+
+ #[test]
+ fn seek_clamps_into_timeline() {
+ let mut clock = clock();
+ clock.seek(MediaTime::from_seconds_f64(99.0).unwrap(), 0.0);
+ assert_eq!(clock.current_time(), clock.total_duration());
+ }
+
+ #[test]
+ fn seek_while_playing_reanchors() {
+ let mut clock = clock();
+ clock.play(0.0);
+ clock.seek(MediaTime::from_seconds_f64(1.0).unwrap(), 5.0);
+ let events = clock.tick(6.0);
+ let expected = MediaTime::from_frame(60, FPS_30).unwrap();
+ assert_eq!(events, vec![PlaybackEvent::Updated(expected)]);
+ }
+
+ #[test]
+ fn volume_mute_cycle_restores_previous() {
+ let mut clock = clock();
+ clock.set_volume(0.7);
+ clock.mute();
+ assert_eq!(clock.volume(), 0.0);
+ assert!(clock.is_muted());
+ clock.unmute();
+ assert_eq!(clock.volume(), 0.7);
+ assert!(!clock.is_muted());
+ }
+
+ #[test]
+ fn zero_volume_counts_as_muted() {
+ let mut clock = clock();
+ clock.set_volume(0.5);
+ clock.set_volume(0.0);
+ assert!(clock.is_muted());
+ clock.unmute();
+ assert_eq!(clock.volume(), 0.5);
+ }
+
+ #[test]
+ fn shrinking_duration_pauses_at_end() {
+ let mut clock = clock();
+ clock.play(0.0);
+ clock.tick(2.0);
+ let events = clock.set_total_duration(MediaTime::from_seconds_f64(1.0).unwrap());
+ assert!(events.contains(&PlaybackEvent::Paused));
+ assert_eq!(clock.current_time(), MediaTime::from_seconds_f64(1.0).unwrap());
+ }
+}