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