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                // A new page (a reload) shows the player paused; it must
354                // not get the frames of a playback it did not start.
355                self.playing = None;
356                self.ready()
357            }
358            Command::Seek { frame } => {
359                self.frame = frame;
360                if let Some((_, _)) = self.playing {
361                    self.playing = Some((now, frame));
362                }
363                self.show()
364            }
365            Command::Step { delta } => {
366                self.playing = None;
367                self.frame = self.frame.saturating_add_signed(delta);
368                let mut out = vec![self.playback()];
369                out.extend(self.show());
370                out
371            }
372            Command::Play => {
373                if self.frame + 1 >= self.engine.total_frames() {
374                    self.frame = 0;
375                }
376                self.playing = Some((now, self.frame));
377                let mut out = vec![self.playback()];
378                out.extend(self.show());
379                out
380            }
381            Command::Pause => {
382                self.playing = None;
383                vec![self.playback()]
384            }
385            Command::SetViewport { w, h, dpr } => {
386                let dpr = if dpr.is_finite() && dpr > 0.0 {
387                    dpr
388                } else {
389                    1.0
390                };
391                self.viewport = (
392                    ((f64::from(w) * dpr) as u32).max(1),
393                    ((f64::from(h) * dpr) as u32).max(1),
394                );
395                self.show()
396            }
397            Command::SetMarker { name, time, scene } => {
398                self.edit(MetaEdit::Marker { name, time, scene })
399            }
400            Command::SetEase { name, value } => self.edit(MetaEdit::Ease { name, value }),
401            Command::SetTunable { name, value } => self.edit(MetaEdit::Tunable { name, value }),
402            Command::SetTransition { scene, seconds } => {
403                self.edit(MetaEdit::Transition { scene, seconds })
404            }
405            Command::Pick { x, y } => {
406                self.selected = self.engine.pick(self.frame, x, y);
407                let mut out = vec![Output::Event(Event::Selected {
408                    node_id: self.selected,
409                })];
410                out.extend(self.show());
411                out
412            }
413            Command::Select { node_id } => {
414                self.selected = node_id;
415                let mut out = vec![Output::Event(Event::Selected { node_id })];
416                out.extend(self.show());
417                out
418            }
419            // Handled by the transport (process exit natively).
420            Command::Shutdown => Vec::new(),
421        }
422    }
423
424    fn playback(&self) -> Output {
425        Output::Event(Event::Playback {
426            playing: self.playing.is_some(),
427            frame: self.frame,
428        })
429    }
430
431    fn edit(&mut self, edit: MetaEdit) -> Vec<Output> {
432        match self.engine.edit(edit) {
433            Ok(changed) => {
434                self.version += 1;
435                self.cache.clear();
436                let mut out: Vec<Output> = changed
437                    .into_iter()
438                    .map(|(scene, meta)| Output::Event(Event::MetaChanged { scene, meta }))
439                    .collect();
440                out.extend(self.timeline(false));
441                out.extend(self.show());
442                out
443            }
444            Err(e) => vec![Self::error(ErrorKind::Meta, e)],
445        }
446    }
447
448    /// Advances playback to wall-clock `now`; returns the new frame's
449    /// outputs, or nothing if the frame did not change.
450    pub fn tick(&mut self, now: f64) -> Vec<Output> {
451        let Some((start, from)) = self.playing else {
452            return Vec::new();
453        };
454        let total = self.engine.total_frames();
455        let elapsed = ((now - start) * f64::from(self.engine.fps()))
456            .floor()
457            .max(0.0) as u32;
458        let mut target = from.saturating_add(elapsed);
459        if target >= total {
460            if self.looping && total > 0 {
461                target %= total;
462                self.playing = Some((now, target));
463            } else {
464                self.playing = None;
465                self.frame = total.saturating_sub(1);
466                let mut out = vec![self.playback()];
467                out.extend(self.show());
468                return out;
469            }
470        }
471        if target == self.frame {
472            return Vec::new();
473        }
474        self.frame = target;
475        self.show()
476    }
477
478    /// Seconds until the next playback frame is due, if playing.
479    pub fn next_tick_in(&self, now: f64) -> Option<f64> {
480        let (start, _) = self.playing?;
481        let dt = 1.0 / f64::from(self.engine.fps());
482        let elapsed = now - start;
483        Some(dt - elapsed.rem_euclid(dt))
484    }
485}
486
487/// Raw RGBA "encoding" (in-process transports, tests).
488pub fn rgba_encoder() -> Encoder {
489    Box::new(|img: &Image| (FrameFormat::Rgba, img.data.clone()))
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    struct Fake {
497        renders: u32,
498        markers: Vec<MarkerInfo>,
499    }
500
501    impl FrameSource for Fake {
502        fn fps(&self) -> u32 {
503            10
504        }
505        fn size(&self) -> (u32, u32) {
506            (200, 100)
507        }
508        fn total_frames(&self) -> u32 {
509            20
510        }
511        fn render(&mut self, frame: u32, size: (u32, u32)) -> Image {
512            self.renders += 1;
513            Image {
514                width: size.0,
515                height: size.1,
516                data: vec![frame as u8; (size.0 * size.1 * 4) as usize],
517            }
518        }
519    }
520
521    impl PreviewEngine for Fake {
522        fn engine_version(&self) -> String {
523            "test".into()
524        }
525        fn scenes(&self) -> Vec<SceneInfo> {
526            vec![SceneInfo {
527                name: "a".into(),
528                start: 0,
529                frames: 20,
530                transition: 0,
531                transition_at: 0,
532            }]
533        }
534        fn markers(&self) -> Vec<MarkerInfo> {
535            self.markers.clone()
536        }
537        fn metas(&self) -> Vec<(String, Meta)> {
538            vec![("a".into(), Meta::default())]
539        }
540        fn edit(&mut self, edit: MetaEdit) -> Result<Vec<(String, Meta)>, String> {
541            match edit {
542                MetaEdit::Marker { name, time, .. } => {
543                    self.markers = vec![MarkerInfo {
544                        scene: "a".into(),
545                        name,
546                        time,
547                    }];
548                    Ok(vec![("a".into(), Meta::default())])
549                }
550                _ => Err("unsupported".into()),
551            }
552        }
553        fn pick(&mut self, _: u32, x: f64, _: f64) -> Option<u32> {
554            (x < 100.0).then_some(1)
555        }
556        fn tree(&mut self, _: u32) -> Vec<NodeInfo> {
557            Vec::new()
558        }
559        fn inspect(&mut self, _: u32, node: u32) -> Option<(Vec<SignalValue>, [f64; 8])> {
560            Some((
561                vec![SignalValue {
562                    name: "id".into(),
563                    value: node.to_string(),
564                }],
565                [0.0; 8],
566            ))
567        }
568        fn waveform(&mut self, _: usize) -> Option<Vec<f32>> {
569            None
570        }
571    }
572
573    fn session() -> Session<Fake> {
574        Session::new(
575            Fake {
576                renders: 0,
577                markers: Vec::new(),
578            },
579            rgba_encoder(),
580            16,
581        )
582    }
583
584    fn events(out: &[Output]) -> Vec<String> {
585        out.iter()
586            .map(|o| match o {
587                Output::Event(e) => serde_json::to_value(e).unwrap()["type"]
588                    .as_str()
589                    .unwrap()
590                    .to_owned(),
591                Output::Frame(b) => format!("bin{}", FrameHeader::decode(b).unwrap().frame),
592            })
593            .collect()
594    }
595
596    #[test]
597    fn handshake_and_version_mismatch() {
598        let mut s = session();
599        let out = s.handle(
600            Command::Hello {
601                protocol_version: PROTOCOL_VERSION,
602                ui_version: "x".into(),
603            },
604            0.0,
605        );
606        assert_eq!(
607            &events(&out)[..5],
608            ["Ready", "MetaChanged", "Timeline", "Frame", "bin0"]
609        );
610        let bad = s.handle(
611            Command::Hello {
612                protocol_version: 999,
613                ui_version: "x".into(),
614            },
615            0.0,
616        );
617        assert!(matches!(
618            &bad[0],
619            Output::Event(Event::Error {
620                kind: ErrorKind::Incompatible,
621                ..
622            })
623        ));
624    }
625
626    #[test]
627    fn a_new_editor_stops_playback() {
628        let mut s = session();
629        s.handle(Command::Play, 10.0);
630        assert!(s.is_playing());
631        s.handle(
632            Command::Hello {
633                protocol_version: PROTOCOL_VERSION,
634                ui_version: "x".into(),
635            },
636            11.0,
637        );
638        assert!(!s.is_playing());
639        assert!(s.tick(12.0).is_empty());
640    }
641
642    #[test]
643    fn viewport_fits_aspect_and_cache_hits() {
644        let mut s = session();
645        s.handle(
646            Command::SetViewport {
647                w: 50,
648                h: 50,
649                dpr: 2.0,
650            },
651            0.0,
652        );
653        assert_eq!(s.render_size(), (100, 50));
654        let renders = s.engine().renders;
655        s.handle(Command::Seek { frame: 0 }, 0.0);
656        assert_eq!(s.engine().renders, renders, "served from cache");
657        s.handle(Command::Seek { frame: 5 }, 0.0);
658        assert_eq!(s.engine().renders, renders + 1);
659    }
660
661    #[test]
662    fn playback_follows_the_clock_and_loops() {
663        let mut s = session();
664        s.handle(Command::Seek { frame: 0 }, 0.0);
665        s.handle(Command::Play, 10.0);
666        assert!(s.tick(10.05).is_empty(), "half a frame later");
667        assert_eq!(events(&s.tick(10.35))[..2], ["Frame", "bin3"]);
668        s.tick(12.15); // 21.5 frames in: loops to 1
669        assert_eq!(s.frame(), 1);
670        s.handle(Command::Pause, 13.0);
671        assert!(!s.is_playing());
672    }
673
674    #[test]
675    fn edits_invalidate_cache_and_report_meta() {
676        let mut s = session();
677        s.handle(Command::Seek { frame: 2 }, 0.0);
678        let out = s.handle(
679            Command::SetMarker {
680                name: "beat".into(),
681                time: 1.0,
682                scene: None,
683            },
684            0.0,
685        );
686        assert_eq!(
687            &events(&out)[..4],
688            ["MetaChanged", "Timeline", "Frame", "bin2"]
689        );
690        let err = s.handle(
691            Command::SetTunable {
692                name: "x".into(),
693                value: 1.0,
694            },
695            0.0,
696        );
697        assert!(matches!(
698            &err[0],
699            Output::Event(Event::Error {
700                kind: ErrorKind::Meta,
701                ..
702            })
703        ));
704    }
705
706    #[test]
707    fn pick_selects_and_streams_signals() {
708        let mut s = session();
709        let out = s.handle(Command::Pick { x: 10.0, y: 10.0 }, 0.0);
710        let names = events(&out);
711        assert_eq!(names[0], "Selected");
712        assert!(names.contains(&"Signals".to_string()));
713    }
714}