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