Skip to main content

konjure_web/
session.rs

1use konjure_sdk::{Diagnostic, Frame, Runtime};
2
3/// Invalid browser input, or SDK evaluation failure. Inputs are transactional.
4#[derive(Debug, thiserror::Error)]
5pub enum BrowserError {
6    /// Scene time or browser timestamp was negative or non-finite.
7    #[error("{0} must be finite and non-negative")]
8    InvalidTime(&'static str),
9    /// An active clock moved backwards without a lifecycle transition.
10    #[error("browser frame timestamp moved backwards")]
11    RegressingTimestamp,
12    /// The SDK rejected a sampled scene; accepted session time is retained.
13    #[error("{0}")]
14    Scene(#[from] Diagnostic),
15}
16
17/// Explicit input from DOM controls and browser lifecycle events.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub enum SessionInput {
20    /// Play or pause animation, resetting the browser timestamp baseline.
21    Playing(bool),
22    /// Seek to a finite, non-negative scene time in seconds.
23    Seek(f64),
24    /// Combined page visibility and viewport intersection state.
25    Visible(bool),
26    /// Whether the host's graphics context can accept rendering work.
27    ContextAvailable(bool),
28    /// Whether an immersive XR session actually owns the animation loop.
29    Immersive(bool),
30}
31
32/// One host-supplied animation callback timestamp, in browser milliseconds.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub struct FrameInput {
35    /// Monotonic `requestAnimationFrame` timestamp within the active clock epoch.
36    pub timestamp_ms: f64,
37}
38
39/// A validated presentation time; the host owns graphics submission.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub struct FrameTiming {
42    /// Absolute scene time in seconds after this frame.
43    pub time_seconds: f64,
44    /// Animation step in seconds, capped at 0.1 to avoid catch-up bursts.
45    pub delta_seconds: f64,
46    /// False for a lost context or a hidden inline view.
47    pub should_render: bool,
48}
49
50/// Lifecycle and clock of a browser view, independent of DOM object ownership.
51#[derive(Clone, Debug)]
52pub struct BrowserSession {
53    time_seconds: f64,
54    previous_ms: Option<f64>,
55    playing: bool,
56    visible: bool,
57    context_available: bool,
58    immersive: bool,
59}
60
61impl BrowserSession {
62    /// Create an inline, visible view with an available graphics context at time zero.
63    pub fn new(playing: bool) -> Self {
64        Self {
65            time_seconds: 0.0,
66            previous_ms: None,
67            playing,
68            visible: true,
69            context_available: true,
70            immersive: false,
71        }
72    }
73
74    /// Apply a lifecycle input; suspension and clock-owner transitions reset the baseline.
75    ///
76    /// # Errors
77    /// A seek to negative or non-finite time is rejected without changing state.
78    pub fn apply(&mut self, input: SessionInput) -> Result<(), BrowserError> {
79        let changed = match input {
80            SessionInput::Playing(value) => {
81                self.playing = value;
82                true
83            }
84            SessionInput::Seek(value) => {
85                check_time(value, "scene time")?;
86                self.time_seconds = value;
87                true
88            }
89            SessionInput::Visible(value) => replace_if_changed(&mut self.visible, value),
90            SessionInput::ContextAvailable(value) => {
91                replace_if_changed(&mut self.context_available, value)
92            }
93            SessionInput::Immersive(value) => replace_if_changed(&mut self.immersive, value),
94        };
95        if changed {
96            self.previous_ms = None;
97        }
98        Ok(())
99    }
100
101    /// Whether authored animation is playing, including while temporarily suspended.
102    pub fn playing(&self) -> bool {
103        self.playing
104    }
105
106    /// Current accepted scene time in seconds.
107    pub fn time_seconds(&self) -> f64 {
108        self.time_seconds
109    }
110
111    /// Whether the host may submit a frame in the current lifecycle state.
112    pub fn active(&self) -> bool {
113        self.context_available && (self.visible || self.immersive)
114    }
115
116    /// Validate and advance one frame. The first frame after a transition has zero delta.
117    ///
118    /// # Errors
119    /// Rejects invalid or backwards timestamps, retaining the previous accepted state.
120    pub fn frame(&mut self, input: FrameInput) -> Result<FrameTiming, BrowserError> {
121        check_time(input.timestamp_ms, "browser timestamp")?;
122        let mut delta_seconds = 0.0;
123        if self.active() {
124            if let Some(previous) = self.previous_ms {
125                if input.timestamp_ms < previous {
126                    return Err(BrowserError::RegressingTimestamp);
127                }
128                if self.playing {
129                    delta_seconds = ((input.timestamp_ms - previous) / 1000.0).min(0.1);
130                }
131            }
132            let time_seconds = self.time_seconds + delta_seconds;
133            check_time(time_seconds, "scene time")?;
134            self.time_seconds = time_seconds;
135            self.previous_ms = Some(input.timestamp_ms);
136        } else {
137            self.previous_ms = None;
138        }
139        Ok(FrameTiming {
140            time_seconds: self.time_seconds,
141            delta_seconds,
142            should_render: self.active(),
143        })
144    }
145
146    /// Sample an SDK scene using this view's clock, or return `None` while suspended.
147    ///
148    /// # Errors
149    /// Invalid timestamps or rejected SDK frames leave the accepted clock unchanged.
150    pub fn sample(
151        &mut self,
152        runtime: &Runtime,
153        input: FrameInput,
154    ) -> Result<Option<Frame>, BrowserError> {
155        let mut candidate = self.clone();
156        let timing = candidate.frame(input)?;
157        let frame = timing
158            .should_render
159            .then(|| runtime.sample(timing.time_seconds))
160            .transpose()?;
161        *self = candidate;
162        Ok(frame)
163    }
164}
165
166fn check_time(value: f64, label: &'static str) -> Result<(), BrowserError> {
167    if !value.is_finite() || value < 0.0 {
168        return Err(BrowserError::InvalidTime(label));
169    }
170    Ok(())
171}
172
173fn replace_if_changed(slot: &mut bool, value: bool) -> bool {
174    let changed = *slot != value;
175    *slot = value;
176    changed
177}