Skip to main content

ograf_core/
protocol.rs

1//! Wire protocol types for OGraf v1 Server API — messages sent between
2//! server and renderer over WebSocket. These types define the JSON structure
3//! on the wire; server-internal bookkeeping lives in `models`.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use uuid::Uuid;
8
9pub type InstanceId = Uuid;
10
11/// Instance state snapshot sent by a renderer during reconnect (Hello message).
12/// Contains the minimal information needed to resync Core's view of what
13/// instances are loaded on the renderer.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct InstanceSnapshot {
17    #[serde(rename = "instanceId")]
18    pub instance_id: InstanceId,
19    #[serde(rename = "graphicId")]
20    pub graphic_id: String,
21    #[serde(default)]
22    pub data: Option<Value>,
23    #[serde(rename = "currentStep", default)]
24    pub current_step: Option<f64>,
25}
26
27/// Identifies a RenderTarget on a Renderer. Per the OGraf spec, its shape is
28/// defined by each Renderer's own `renderTargetSchema` — Core treats it as
29/// an opaque, shallow JSON object and only ever compares it for equality.
30/// For example, a CasparCG renderer might use `{channel, layer}`, fixed for
31/// the lifetime of one WS connection; other renderer types (eg a file-render
32/// worker) are free to use a different shape (eg `{profile: "16:9-1080p"}`).
33pub type RenderTarget = Value;
34
35/// Messages sent from the server to a renderer over its WebSocket. Every
36/// variant carries a `request_id` so the HTTP handler that triggered it can
37/// correlate the renderer's eventual `*Result` reply (see RendererMessage)
38/// and return the real statusCode/statusMessage, per the OGraf spec.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "camelCase")]
41pub enum ServerMessage {
42    Load {
43        #[serde(rename = "requestId")]
44        request_id: Uuid,
45        #[serde(rename = "instanceId")]
46        instance_id: InstanceId,
47        #[serde(rename = "graphicId")]
48        graphic_id: String,
49        data: Option<Value>,
50    },
51    PlayAction {
52        #[serde(rename = "requestId")]
53        request_id: Uuid,
54        #[serde(rename = "instanceId")]
55        instance_id: InstanceId,
56        #[serde(skip_serializing_if = "Option::is_none")]
57        goto: Option<f64>,
58        #[serde(skip_serializing_if = "Option::is_none")]
59        delta: Option<f64>,
60        #[serde(rename = "skipAnimation", skip_serializing_if = "Option::is_none")]
61        skip_animation: Option<bool>,
62    },
63    StopAction {
64        #[serde(rename = "requestId")]
65        request_id: Uuid,
66        #[serde(rename = "instanceId")]
67        instance_id: InstanceId,
68        #[serde(rename = "skipAnimation", skip_serializing_if = "Option::is_none")]
69        skip_animation: Option<bool>,
70    },
71    UpdateAction {
72        #[serde(rename = "requestId")]
73        request_id: Uuid,
74        #[serde(rename = "instanceId")]
75        instance_id: InstanceId,
76        data: Value,
77        #[serde(rename = "skipAnimation", skip_serializing_if = "Option::is_none")]
78        skip_animation: Option<bool>,
79    },
80    CustomAction {
81        #[serde(rename = "requestId")]
82        request_id: Uuid,
83        #[serde(rename = "instanceId")]
84        instance_id: InstanceId,
85        #[serde(rename = "actionId")]
86        action_id: String,
87        payload: Value,
88        #[serde(rename = "skipAnimation", skip_serializing_if = "Option::is_none")]
89        skip_animation: Option<bool>,
90    },
91    /// A custom action invoked on the Renderer itself, not on a GraphicInstance.
92    RendererCustomAction {
93        #[serde(rename = "requestId")]
94        request_id: Uuid,
95        #[serde(rename = "actionId")]
96        action_id: String,
97        payload: Value,
98        #[serde(rename = "skipAnimation", skip_serializing_if = "Option::is_none")]
99        skip_animation: Option<bool>,
100    },
101    Clear {
102        #[serde(rename = "requestId")]
103        request_id: Uuid,
104        #[serde(rename = "instanceId")]
105        instance_id: InstanceId,
106    },
107}
108
109impl ServerMessage {
110    /// Smart constructor for PlayAction with `goto` — makes invalid state
111    /// (both goto and delta set) unrepresentable.
112    pub fn play_goto(
113        request_id: Uuid,
114        instance_id: InstanceId,
115        goto: f64,
116        skip_animation: Option<bool>,
117    ) -> Self {
118        ServerMessage::PlayAction {
119            request_id,
120            instance_id,
121            goto: Some(goto),
122            delta: None,
123            skip_animation,
124        }
125    }
126
127    /// Smart constructor for PlayAction with `delta` — makes invalid state
128    /// (both goto and delta set) unrepresentable.
129    pub fn play_delta(
130        request_id: Uuid,
131        instance_id: InstanceId,
132        delta: f64,
133        skip_animation: Option<bool>,
134    ) -> Self {
135        ServerMessage::PlayAction {
136            request_id,
137            instance_id,
138            goto: None,
139            delta: Some(delta),
140            skip_animation,
141        }
142    }
143
144    /// Smart constructor for PlayAction with neither goto nor delta —
145    /// continues from current step.
146    pub fn play_continue(
147        request_id: Uuid,
148        instance_id: InstanceId,
149        skip_animation: Option<bool>,
150    ) -> Self {
151        ServerMessage::PlayAction {
152            request_id,
153            instance_id,
154            goto: None,
155            delta: None,
156            skip_animation,
157        }
158    }
159}
160
161/// Accepts whatever JSON value shows up and coerces it to a step number,
162/// defaulting to 0.0 rather than failing — see `RendererMessage::PlayActionResult`.
163fn lenient_f64<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
164where
165    D: serde::Deserializer<'de>,
166{
167    Ok(Value::deserialize(deserializer)?.as_f64().unwrap_or(0.0))
168}
169
170/// Messages received from a renderer over its WebSocket.
171#[derive(Debug, Clone, Deserialize)]
172#[serde(tag = "type", rename_all = "camelCase")]
173pub enum RendererMessage {
174    Hello {
175        name: String,
176        #[serde(rename = "renderTarget")]
177        render_target: Value,
178        #[serde(default)]
179        capabilities: Value,
180        /// Optional instance state snapshots for reconnect state resync — if
181        /// present, Core will populate the session's instances from this list
182        /// rather than starting empty (Wish 3: renderer reconnect state resync).
183        #[serde(default)]
184        instances: Option<Vec<InstanceSnapshot>>,
185    },
186    Ping,
187    LoadResult {
188        #[serde(rename = "requestId")]
189        request_id: Uuid,
190        #[serde(rename = "instanceId")]
191        instance_id: InstanceId,
192        #[serde(rename = "graphicId")]
193        graphic_id: String,
194        #[serde(default)]
195        data: Option<Value>,
196        #[serde(rename = "statusCode")]
197        status_code: u16,
198        #[serde(rename = "statusMessage", default)]
199        status_message: Option<String>,
200    },
201    PlayActionResult {
202        #[serde(rename = "requestId")]
203        request_id: Uuid,
204        #[serde(rename = "instanceId")]
205        instance_id: InstanceId,
206        #[serde(rename = "statusCode")]
207        status_code: u16,
208        #[serde(rename = "statusMessage", default)]
209        status_message: Option<String>,
210        // Lenient on purpose: `currentStep` is whatever a template's own
211        // playAction() happened to return — a template that returns something
212        // non-numeric shouldn't sink the *entire* message (and hang the HTTP
213        // caller for the full timeout) just because of this one field.
214        #[serde(rename = "currentStep", default, deserialize_with = "lenient_f64")]
215        current_step: f64,
216    },
217    StopActionResult {
218        #[serde(rename = "requestId")]
219        request_id: Uuid,
220        #[serde(rename = "instanceId")]
221        instance_id: InstanceId,
222        #[serde(rename = "statusCode")]
223        status_code: u16,
224        #[serde(rename = "statusMessage", default)]
225        status_message: Option<String>,
226    },
227    UpdateActionResult {
228        #[serde(rename = "requestId")]
229        request_id: Uuid,
230        #[serde(rename = "instanceId")]
231        instance_id: InstanceId,
232        #[serde(default)]
233        data: Option<Value>,
234        #[serde(rename = "statusCode")]
235        status_code: u16,
236        #[serde(rename = "statusMessage", default)]
237        status_message: Option<String>,
238    },
239    CustomActionResult {
240        #[serde(rename = "requestId")]
241        request_id: Uuid,
242        #[serde(rename = "instanceId")]
243        instance_id: InstanceId,
244        #[serde(rename = "statusCode")]
245        status_code: u16,
246        #[serde(rename = "statusMessage", default)]
247        status_message: Option<String>,
248    },
249    RendererCustomActionResult {
250        #[serde(rename = "requestId")]
251        request_id: Uuid,
252        #[serde(rename = "statusCode")]
253        status_code: u16,
254        #[serde(rename = "statusMessage", default)]
255        status_message: Option<String>,
256        #[serde(default)]
257        result: Option<Value>,
258    },
259    ClearResult {
260        #[serde(rename = "requestId")]
261        request_id: Uuid,
262        #[serde(rename = "instanceId")]
263        instance_id: InstanceId,
264        #[serde(rename = "statusCode")]
265        status_code: u16,
266        #[serde(rename = "statusMessage", default)]
267        status_message: Option<String>,
268    },
269}
270
271impl RendererMessage {
272    /// The correlation id this message is a reply to, if any (`Hello`/`Ping`
273    /// carry none since nothing on the server side is awaiting them).
274    pub fn request_id(&self) -> Option<Uuid> {
275        match self {
276            RendererMessage::LoadResult { request_id, .. }
277            | RendererMessage::PlayActionResult { request_id, .. }
278            | RendererMessage::StopActionResult { request_id, .. }
279            | RendererMessage::UpdateActionResult { request_id, .. }
280            | RendererMessage::CustomActionResult { request_id, .. }
281            | RendererMessage::RendererCustomActionResult { request_id, .. }
282            | RendererMessage::ClearResult { request_id, .. } => Some(*request_id),
283            RendererMessage::Hello { .. } | RendererMessage::Ping => None,
284        }
285    }
286
287    /// Whether this result message indicates success (status_code < 400).
288    /// Returns `false` for Hello/Ping which carry no status code.
289    pub fn is_success(&self) -> bool {
290        match self {
291            RendererMessage::LoadResult { status_code, .. }
292            | RendererMessage::PlayActionResult { status_code, .. }
293            | RendererMessage::StopActionResult { status_code, .. }
294            | RendererMessage::UpdateActionResult { status_code, .. }
295            | RendererMessage::CustomActionResult { status_code, .. }
296            | RendererMessage::RendererCustomActionResult { status_code, .. }
297            | RendererMessage::ClearResult { status_code, .. } => *status_code < 400,
298            RendererMessage::Hello { .. } | RendererMessage::Ping => false,
299        }
300    }
301}