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