Skip to main content

oxframe_preview/
lib.rs

1//! Preview protocol for oxframe.
2//!
3//! > **Status:** oxframe is under very active development. The API is not stable yet and
4//! > may change from version to version. Pin the exact version in your Cargo.toml and check
5//! > the [changelog](https://github.com/oxygenframe/oxframe-sdk/blob/main/CHANGELOG.md)
6//! > before upgrading.
7//!
8//! [`Session`] implements the editor protocol (`oxframe-protocol`) on top of
9//! any [`PreviewEngine`] and is independent of the transport:
10//!
11//! - natively, [`server`] exposes it over a WebSocket on `127.0.0.1`
12//!   (frames are JPEG-encoded and cached);
13//! - in the browser, the same session runs inside wasm and frames go to a
14//!   canvas as raw RGBA.
15
16#![warn(missing_docs)]
17
18use std::collections::VecDeque;
19
20use oxframe_core::{FrameSource, Image};
21use oxframe_protocol::{
22    Command, ErrorKind, Event, FrameFormat, FrameHeader, MarkerInfo, Meta, NodeInfo,
23    PROTOCOL_VERSION, SceneInfo, SignalValue,
24};
25
26#[cfg(all(feature = "server", not(target_arch = "wasm32")))]
27pub mod server;
28
29/// An edit of a scene's `meta.json` requested by the editor.
30#[derive(Debug, Clone, PartialEq)]
31pub enum MetaEdit {
32    /// Move or create a marker.
33    Marker {
34        /// Marker name.
35        name: String,
36        /// Seconds from the start of the scene.
37        time: f64,
38        /// Scene to create it in if it does not exist.
39        scene: Option<String>,
40    },
41    /// Change a named ease.
42    Ease {
43        /// Ease name.
44        name: String,
45        /// Control points `[x1, y1, x2, y2]`.
46        value: [f64; 4],
47    },
48    /// Change a tunable.
49    Tunable {
50        /// Tunable name.
51        name: String,
52        /// New value.
53        value: f64,
54    },
55    /// Change how long a scene's incoming transition lasts.
56    Transition {
57        /// Scene the transition belongs to.
58        scene: String,
59        /// Blend length in seconds. Zero removes it.
60        seconds: f64,
61    },
62}
63
64/// What the preview needs from an engine, on top of drawing frames
65/// ([`FrameSource`]). Implemented by `oxframe::PreviewAdapter`.
66pub trait PreviewEngine: FrameSource {
67    /// Version string sent in `Ready`.
68    fn engine_version(&self) -> String;
69    /// Scenes with their first frame and length.
70    fn scenes(&self) -> Vec<SceneInfo>;
71    /// Markers of all scenes.
72    fn markers(&self) -> Vec<MarkerInfo>;
73    /// Meta of every scene (sent on `Ready` so the editor knows eases and
74    /// tunables).
75    fn metas(&self) -> Vec<(String, Meta)>;
76    /// Applies an edit, re-simulates, persists the meta; returns changed
77    /// metas as `(scene, meta)`.
78    ///
79    /// # Errors
80    ///
81    /// A message for the editor if the edit is invalid or saving failed.
82    fn edit(&mut self, edit: MetaEdit) -> Result<Vec<(String, Meta)>, String>;
83    /// Hit-test at frame pixels (top-left origin, project size).
84    fn pick(&mut self, frame: u32, x: f64, y: f64) -> Option<u32>;
85    /// Node tree of the scene at `frame`.
86    fn tree(&mut self, frame: u32) -> Vec<NodeInfo>;
87    /// Signal values and selection box (frame pixels) of a node.
88    fn inspect(&mut self, frame: u32, node: u32) -> Option<(Vec<SignalValue>, [f64; 8])>;
89    /// Audio peak envelope for the timeline, if the project has audio.
90    fn waveform(&mut self, buckets: usize) -> Option<Vec<f32>>;
91}
92
93/// A message for the UI: JSON event or binary frame.
94#[derive(Debug, Clone)]
95pub enum Output {
96    /// A JSON event.
97    Event(Event),
98    /// Binary WS message: 16-byte header followed by the body.
99    Frame(Vec<u8>),
100}
101
102/// Encodes a rendered frame for the wire.
103pub type Encoder = Box<dyn FnMut(&Image) -> (FrameFormat, Vec<u8>)>;
104
105/// (frame, width, height, meta version).
106type CacheKey = (u32, u32, u32, u64);
107
108/// Frame cache bounded by total bytes.
109struct FrameCache {
110    limit: usize,
111    used: usize,
112    entries: VecDeque<(CacheKey, Vec<u8>)>,
113}
114
115impl FrameCache {
116    fn get(&mut self, key: CacheKey) -> Option<Vec<u8>> {
117        let pos = self.entries.iter().position(|(k, _)| *k == key)?;
118        let entry = self.entries.remove(pos)?;
119        let bytes = entry.1.clone();
120        self.entries.push_back(entry);
121        Some(bytes)
122    }
123
124    fn put(&mut self, key: CacheKey, bytes: Vec<u8>) {
125        self.used += bytes.len();
126        self.entries.push_back((key, bytes));
127        while self.used > self.limit {
128            let Some((_, old)) = self.entries.pop_front() else {
129                break;
130            };
131            self.used -= old.len();
132        }
133    }
134
135    fn clear(&mut self) {
136        self.entries.clear();
137        self.used = 0;
138    }
139}
140
141/// Transport-independent preview state machine: feed it [`Command`]s, send its [`Output`]s to the UI.
142pub struct Session<E> {
143    engine: E,
144    encode: Encoder,
145    frame: u32,
146    playing: Option<(f64, u32)>,
147    looping: bool,
148    viewport: (u32, u32),
149    selected: Option<u32>,
150    /// Bumped on every meta edit; part of the cache key.
151    version: u64,
152    cache: FrameCache,
153    last_tree: Vec<NodeInfo>,
154}
155
156impl<E: PreviewEngine> Session<E> {
157    /// `cache_mb` bounds the frame cache.
158    pub fn new(engine: E, encode: Encoder, cache_mb: usize) -> Self {
159        let size = engine.size();
160        Self {
161            engine,
162            encode,
163            frame: 0,
164            playing: None,
165            looping: true,
166            viewport: (size.0.min(960), size.1.min(540)),
167            selected: None,
168            version: 0,
169            cache: FrameCache {
170                limit: cache_mb * 1024 * 1024,
171                used: 0,
172                entries: VecDeque::new(),
173            },
174            last_tree: Vec::new(),
175        }
176    }
177
178    /// The wrapped engine.
179    pub fn engine(&mut self) -> &mut E {
180        &mut self.engine
181    }
182
183    /// Current frame.
184    pub fn frame(&self) -> u32 {
185        self.frame
186    }
187
188    /// Whether playback is running.
189    pub fn is_playing(&self) -> bool {
190        self.playing.is_some()
191    }
192
193    /// Size of preview frames: the viewport, fitted to the project aspect
194    /// and never larger than the project.
195    pub fn render_size(&self) -> (u32, u32) {
196        let (w, h) = self.engine.size();
197        let (vw, vh) = self.viewport;
198        let k = (f64::from(vw) / f64::from(w))
199            .min(f64::from(vh) / f64::from(h))
200            .min(1.0);
201        (
202            ((f64::from(w) * k).round() as u32).max(1),
203            ((f64::from(h) * k).round() as u32).max(1),
204        )
205    }
206
207    fn ready(&mut self) -> Vec<Output> {
208        let (w, h) = self.engine.size();
209        let mut out = vec![Output::Event(Event::Ready {
210            protocol_version: PROTOCOL_VERSION,
211            engine_version: self.engine.engine_version(),
212            fps: self.engine.fps(),
213            size: [w, h],
214            scenes: self.engine.scenes(),
215        })];
216        out.extend(
217            self.engine
218                .metas()
219                .into_iter()
220                .map(|(scene, meta)| Output::Event(Event::MetaChanged { scene, meta })),
221        );
222        out.push(self.timeline(true));
223        out.extend(self.show());
224        out
225    }
226
227    fn timeline(&mut self, with_audio: bool) -> Output {
228        let frames = self.engine.total_frames();
229        Output::Event(Event::Timeline {
230            frames,
231            markers: self.engine.markers(),
232            scenes: self.engine.scenes(),
233            audio_waveform: if with_audio {
234                self.engine.waveform((frames as usize).clamp(1, 4096))
235            } else {
236                None
237            },
238        })
239    }
240
241    /// Frame plus everything that depends on it.
242    fn show(&mut self) -> Vec<Output> {
243        let total = self.engine.total_frames();
244        self.frame = self.frame.min(total.saturating_sub(1));
245        let size = self.render_size();
246        let key = (self.frame, size.0, size.1, self.version);
247        let body = match self.cache.get(key) {
248            Some(b) => b,
249            None => {
250                let img = self.engine.render(self.frame, size);
251                let (format, bytes) = (self.encode)(&img);
252                let header = FrameHeader {
253                    frame: self.frame,
254                    width: img.width as u16,
255                    height: img.height as u16,
256                    format,
257                    len: bytes.len() as u32,
258                };
259                let mut msg = Vec::with_capacity(FrameHeader::SIZE + bytes.len());
260                msg.extend_from_slice(&header.encode());
261                msg.extend_from_slice(&bytes);
262                // Surface frames live in the canvas, not in the message.
263                if format != FrameFormat::Surface {
264                    self.cache.put(key, msg.clone());
265                }
266                msg
267            }
268        };
269        let mut out = vec![
270            Output::Event(Event::Frame { index: self.frame }),
271            Output::Frame(body),
272        ];
273        let tree = self.engine.tree(self.frame);
274        if tree != self.last_tree {
275            self.last_tree = tree.clone();
276            out.push(Output::Event(Event::Tree { nodes: tree }));
277        }
278        if let Some(id) = self.selected {
279            match self.engine.inspect(self.frame, id) {
280                Some((values, bounds)) => out.push(Output::Event(Event::Signals {
281                    node_id: id,
282                    values,
283                    bounds: Some(bounds),
284                })),
285                None => {
286                    self.selected = None;
287                    out.push(Output::Event(Event::Selected { node_id: None }));
288                }
289            }
290        }
291        out
292    }
293
294    fn error(kind: ErrorKind, message: impl Into<String>) -> Output {
295        Output::Event(Event::Error {
296            kind,
297            message: message.into(),
298            spans: Vec::new(),
299        })
300    }
301
302    /// Handles one command. `now` is a monotonic clock in seconds, used to
303    /// pace playback.
304    pub fn handle(&mut self, cmd: Command, now: f64) -> Vec<Output> {
305        match cmd {
306            Command::Hello {
307                protocol_version, ..
308            } => {
309                if protocol_version != PROTOCOL_VERSION {
310                    return vec![Self::error(
311                        ErrorKind::Incompatible,
312                        format!(
313                            "editor speaks protocol v{protocol_version}, engine speaks v{PROTOCOL_VERSION}; \
314                             update oxframe-cli or the SDK so they match"
315                        ),
316                    )];
317                }
318                self.ready()
319            }
320            Command::Seek { frame } => {
321                self.frame = frame;
322                if let Some((_, _)) = self.playing {
323                    self.playing = Some((now, frame));
324                }
325                self.show()
326            }
327            Command::Step { delta } => {
328                self.playing = None;
329                self.frame = self.frame.saturating_add_signed(delta);
330                let mut out = vec![self.playback()];
331                out.extend(self.show());
332                out
333            }
334            Command::Play => {
335                if self.frame + 1 >= self.engine.total_frames() {
336                    self.frame = 0;
337                }
338                self.playing = Some((now, self.frame));
339                let mut out = vec![self.playback()];
340                out.extend(self.show());
341                out
342            }
343            Command::Pause => {
344                self.playing = None;
345                vec![self.playback()]
346            }
347            Command::SetViewport { w, h, dpr } => {
348                let dpr = if dpr.is_finite() && dpr > 0.0 {
349                    dpr
350                } else {
351                    1.0
352                };
353                self.viewport = (
354                    ((f64::from(w) * dpr) as u32).max(1),
355                    ((f64::from(h) * dpr) as u32).max(1),
356                );
357                self.show()
358            }
359            Command::SetMarker { name, time, scene } => {
360                self.edit(MetaEdit::Marker { name, time, scene })
361            }
362            Command::SetEase { name, value } => self.edit(MetaEdit::Ease { name, value }),
363            Command::SetTunable { name, value } => self.edit(MetaEdit::Tunable { name, value }),
364            Command::SetTransition { scene, seconds } => {
365                self.edit(MetaEdit::Transition { scene, seconds })
366            }
367            Command::Pick { x, y } => {
368                self.selected = self.engine.pick(self.frame, x, y);
369                let mut out = vec![Output::Event(Event::Selected {
370                    node_id: self.selected,
371                })];
372                out.extend(self.show());
373                out
374            }
375            Command::Select { node_id } => {
376                self.selected = node_id;
377                let mut out = vec![Output::Event(Event::Selected { node_id })];
378                out.extend(self.show());
379                out
380            }
381            // Handled by the transport (process exit natively).
382            Command::Shutdown => Vec::new(),
383        }
384    }
385
386    fn playback(&self) -> Output {
387        Output::Event(Event::Playback {
388            playing: self.playing.is_some(),
389            frame: self.frame,
390        })
391    }
392
393    fn edit(&mut self, edit: MetaEdit) -> Vec<Output> {
394        match self.engine.edit(edit) {
395            Ok(changed) => {
396                self.version += 1;
397                self.cache.clear();
398                let mut out: Vec<Output> = changed
399                    .into_iter()
400                    .map(|(scene, meta)| Output::Event(Event::MetaChanged { scene, meta }))
401                    .collect();
402                out.push(self.timeline(false));
403                out.extend(self.show());
404                out
405            }
406            Err(e) => vec![Self::error(ErrorKind::Meta, e)],
407        }
408    }
409
410    /// Advances playback to wall-clock `now`; returns the new frame's
411    /// outputs, or nothing if the frame did not change.
412    pub fn tick(&mut self, now: f64) -> Vec<Output> {
413        let Some((start, from)) = self.playing else {
414            return Vec::new();
415        };
416        let total = self.engine.total_frames();
417        let elapsed = ((now - start) * f64::from(self.engine.fps()))
418            .floor()
419            .max(0.0) as u32;
420        let mut target = from.saturating_add(elapsed);
421        if target >= total {
422            if self.looping && total > 0 {
423                target %= total;
424                self.playing = Some((now, target));
425            } else {
426                self.playing = None;
427                self.frame = total.saturating_sub(1);
428                let mut out = vec![self.playback()];
429                out.extend(self.show());
430                return out;
431            }
432        }
433        if target == self.frame {
434            return Vec::new();
435        }
436        self.frame = target;
437        self.show()
438    }
439
440    /// Seconds until the next playback frame is due, if playing.
441    pub fn next_tick_in(&self, now: f64) -> Option<f64> {
442        let (start, _) = self.playing?;
443        let dt = 1.0 / f64::from(self.engine.fps());
444        let elapsed = now - start;
445        Some(dt - elapsed.rem_euclid(dt))
446    }
447}
448
449/// Raw RGBA "encoding" (in-process transports, tests).
450pub fn rgba_encoder() -> Encoder {
451    Box::new(|img: &Image| (FrameFormat::Rgba, img.data.clone()))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    struct Fake {
459        renders: u32,
460        markers: Vec<MarkerInfo>,
461    }
462
463    impl FrameSource for Fake {
464        fn fps(&self) -> u32 {
465            10
466        }
467        fn size(&self) -> (u32, u32) {
468            (200, 100)
469        }
470        fn total_frames(&self) -> u32 {
471            20
472        }
473        fn render(&mut self, frame: u32, size: (u32, u32)) -> Image {
474            self.renders += 1;
475            Image {
476                width: size.0,
477                height: size.1,
478                data: vec![frame as u8; (size.0 * size.1 * 4) as usize],
479            }
480        }
481    }
482
483    impl PreviewEngine for Fake {
484        fn engine_version(&self) -> String {
485            "test".into()
486        }
487        fn scenes(&self) -> Vec<SceneInfo> {
488            vec![SceneInfo {
489                name: "a".into(),
490                start: 0,
491                frames: 20,
492                transition: 0,
493                transition_at: 0,
494            }]
495        }
496        fn markers(&self) -> Vec<MarkerInfo> {
497            self.markers.clone()
498        }
499        fn metas(&self) -> Vec<(String, Meta)> {
500            vec![("a".into(), Meta::default())]
501        }
502        fn edit(&mut self, edit: MetaEdit) -> Result<Vec<(String, Meta)>, String> {
503            match edit {
504                MetaEdit::Marker { name, time, .. } => {
505                    self.markers = vec![MarkerInfo {
506                        scene: "a".into(),
507                        name,
508                        time,
509                    }];
510                    Ok(vec![("a".into(), Meta::default())])
511                }
512                _ => Err("unsupported".into()),
513            }
514        }
515        fn pick(&mut self, _: u32, x: f64, _: f64) -> Option<u32> {
516            (x < 100.0).then_some(1)
517        }
518        fn tree(&mut self, _: u32) -> Vec<NodeInfo> {
519            Vec::new()
520        }
521        fn inspect(&mut self, _: u32, node: u32) -> Option<(Vec<SignalValue>, [f64; 8])> {
522            Some((
523                vec![SignalValue {
524                    name: "id".into(),
525                    value: node.to_string(),
526                }],
527                [0.0; 8],
528            ))
529        }
530        fn waveform(&mut self, _: usize) -> Option<Vec<f32>> {
531            None
532        }
533    }
534
535    fn session() -> Session<Fake> {
536        Session::new(
537            Fake {
538                renders: 0,
539                markers: Vec::new(),
540            },
541            rgba_encoder(),
542            16,
543        )
544    }
545
546    fn events(out: &[Output]) -> Vec<String> {
547        out.iter()
548            .map(|o| match o {
549                Output::Event(e) => serde_json::to_value(e).unwrap()["type"]
550                    .as_str()
551                    .unwrap()
552                    .to_owned(),
553                Output::Frame(b) => format!("bin{}", FrameHeader::decode(b).unwrap().frame),
554            })
555            .collect()
556    }
557
558    #[test]
559    fn handshake_and_version_mismatch() {
560        let mut s = session();
561        let out = s.handle(
562            Command::Hello {
563                protocol_version: PROTOCOL_VERSION,
564                ui_version: "x".into(),
565            },
566            0.0,
567        );
568        assert_eq!(
569            &events(&out)[..5],
570            ["Ready", "MetaChanged", "Timeline", "Frame", "bin0"]
571        );
572        let bad = s.handle(
573            Command::Hello {
574                protocol_version: 999,
575                ui_version: "x".into(),
576            },
577            0.0,
578        );
579        assert!(matches!(
580            &bad[0],
581            Output::Event(Event::Error {
582                kind: ErrorKind::Incompatible,
583                ..
584            })
585        ));
586    }
587
588    #[test]
589    fn viewport_fits_aspect_and_cache_hits() {
590        let mut s = session();
591        s.handle(
592            Command::SetViewport {
593                w: 50,
594                h: 50,
595                dpr: 2.0,
596            },
597            0.0,
598        );
599        assert_eq!(s.render_size(), (100, 50));
600        let renders = s.engine().renders;
601        s.handle(Command::Seek { frame: 0 }, 0.0);
602        assert_eq!(s.engine().renders, renders, "served from cache");
603        s.handle(Command::Seek { frame: 5 }, 0.0);
604        assert_eq!(s.engine().renders, renders + 1);
605    }
606
607    #[test]
608    fn playback_follows_the_clock_and_loops() {
609        let mut s = session();
610        s.handle(Command::Seek { frame: 0 }, 0.0);
611        s.handle(Command::Play, 10.0);
612        assert!(s.tick(10.05).is_empty(), "half a frame later");
613        assert_eq!(events(&s.tick(10.35))[..2], ["Frame", "bin3"]);
614        s.tick(12.15); // 21.5 frames in: loops to 1
615        assert_eq!(s.frame(), 1);
616        s.handle(Command::Pause, 13.0);
617        assert!(!s.is_playing());
618    }
619
620    #[test]
621    fn edits_invalidate_cache_and_report_meta() {
622        let mut s = session();
623        s.handle(Command::Seek { frame: 2 }, 0.0);
624        let out = s.handle(
625            Command::SetMarker {
626                name: "beat".into(),
627                time: 1.0,
628                scene: None,
629            },
630            0.0,
631        );
632        assert_eq!(
633            &events(&out)[..4],
634            ["MetaChanged", "Timeline", "Frame", "bin2"]
635        );
636        let err = s.handle(
637            Command::SetTunable {
638                name: "x".into(),
639                value: 1.0,
640            },
641            0.0,
642        );
643        assert!(matches!(
644            &err[0],
645            Output::Event(Event::Error {
646                kind: ErrorKind::Meta,
647                ..
648            })
649        ));
650    }
651
652    #[test]
653    fn pick_selects_and_streams_signals() {
654        let mut s = session();
655        let out = s.handle(Command::Pick { x: 10.0, y: 10.0 }, 0.0);
656        let names = events(&out);
657        assert_eq!(names[0], "Selected");
658        assert!(names.contains(&"Signals".to_string()));
659    }
660}