Skip to main content

oxframe_preview/
lib.rs

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