Skip to main content

videosdk/resources/
egress.rs

1//! Composition settings and handles shared by every egress: recordings, HLS and
2//! RTMP.
3
4use serde::Serialize;
5use serde_json::Value;
6
7use crate::common::{
8    CompositionConfig, CompositionQuality, LayoutConfig, LayoutPriority, LayoutType, Orientation,
9    Theme,
10};
11
12/// A composition layout: one of the named layouts, or a custom template.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CompositionLayout {
15    /// An even grid of participants.
16    Grid,
17    /// Highlights the active participant.
18    Spotlight,
19    /// A main stage with a sidebar of participants.
20    Sidebar,
21    /// Renders a custom template, given its URL, instead of a named layout.
22    Custom(String),
23}
24
25impl CompositionLayout {
26    /// A custom-template layout.
27    pub fn custom(template_url: impl Into<String>) -> Self {
28        CompositionLayout::Custom(template_url.into())
29    }
30
31    /// The wire layout type of a named layout, or `None` for a custom template.
32    fn layout_type(&self) -> Option<LayoutType> {
33        match self {
34            CompositionLayout::Grid => Some(LayoutType::Grid),
35            CompositionLayout::Spotlight => Some(LayoutType::Spotlight),
36            CompositionLayout::Sidebar => Some(LayoutType::Sidebar),
37            CompositionLayout::Custom(_) => None,
38        }
39    }
40}
41
42/// The composition options shared by every egress: layout, priority, grid size,
43/// orientation, theme and quality.
44///
45/// If `layout` is omitted while `quality`, `theme` or `orientation` are set, the
46/// layout defaults to [`CompositionLayout::Grid`] — the API requires a layout
47/// whenever any other composition option is present.
48#[derive(Debug, Clone, Default)]
49pub struct Composition {
50    /// A named layout, or a custom template.
51    pub layout: Option<CompositionLayout>,
52    /// The speaker priority. HLS and RTMP require it when a layout is present,
53    /// so the SDK defaults it to [`LayoutPriority::Speaker`] for those.
54    pub priority: Option<LayoutPriority>,
55    /// The number of tiles in a grid layout: 0-25, defaulting to 25.
56    pub grid_size: Option<u32>,
57    /// The composition's orientation.
58    pub orientation: Option<Orientation>,
59    /// The composition's theme.
60    pub theme: Option<Theme>,
61    /// The recorder quality tier.
62    pub quality: Option<CompositionQuality>,
63}
64
65/// The request-body composition settings produced by [`composition_to_config`].
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub(crate) struct MappedComposition {
68    pub(crate) config: Option<CompositionConfig>,
69    pub(crate) template_url: Option<String>,
70}
71
72/// Converts a [`Composition`] into the request-body composition settings.
73///
74/// `default_priority` is applied to a named layout that has no explicit
75/// priority; HLS and RTMP require a priority whenever a layout is present.
76pub(crate) fn composition_to_config(
77    composition: Option<&Composition>,
78    default_priority: Option<LayoutPriority>,
79) -> MappedComposition {
80    let Some(composition) = composition else {
81        return MappedComposition::default();
82    };
83
84    let priority = || composition.priority.or(default_priority);
85    let mut config = CompositionConfig::default();
86    let mut template_url = None;
87
88    match &composition.layout {
89        Some(CompositionLayout::Custom(url)) => template_url = Some(url.clone()),
90        Some(named) => {
91            config.layout = Some(LayoutConfig {
92                kind: named.layout_type(),
93                priority: priority(),
94                grid_size: composition.grid_size,
95            })
96        }
97        None => {}
98    }
99
100    config.orientation = composition.orientation;
101    config.theme = composition.theme;
102    config.quality = composition.quality;
103
104    // The API rejects (HTTP 406) any non-empty `config` that has no `layout` —
105    // even alongside a `templateUrl`. If the caller set quality, theme or
106    // orientation without a named layout, default the layout to GRID so the
107    // composition starts. With a custom template, the top-level `templateUrl`
108    // remains the more specific layout directive downstream.
109    if !config.is_empty() && config.layout.is_none() {
110        config.layout = Some(LayoutConfig {
111            kind: Some(LayoutType::Grid),
112            priority: priority(),
113            grid_size: None,
114        });
115    }
116
117    MappedComposition {
118        config: (!config.is_empty()).then_some(config),
119        template_url,
120    }
121}
122
123/* -------------------------------- egress handles ------------------------------- */
124
125/// The kind of egress an [`EgressHandle`] refers to.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum EgressType {
128    /// A room (composed) recording.
129    Recording,
130    /// A server-side composed recording.
131    Composite,
132    /// An HLS stream.
133    Hls,
134    /// An RTMP-out livestream.
135    Livestream,
136}
137
138/// A handle to a running egress.
139///
140/// `start` returns one; pass it — or a bare room id — to `stop`.
141///
142/// Recordings, HLS and RTMP stop by `room_id`, where `id` is only an optional
143/// disambiguator. **Composite** recordings stop by `id` (their `recordingId`),
144/// which their start response provides.
145#[derive(Debug, Clone)]
146pub struct EgressHandle {
147    /// Which egress this handle refers to.
148    pub kind: EgressType,
149    /// The room the egress runs in — the primary stop key.
150    pub room_id: String,
151    /// The server id, when the start response provides one.
152    pub id: Option<String>,
153    /// The session correlation id, when the start response provides one.
154    pub session_id: Option<String>,
155    /// The raw start response: a confirmation string, or an object.
156    pub raw: Option<Value>,
157}
158
159/// Builds a handle from a start response, which is sometimes a bare confirmation
160/// string and sometimes an id-bearing object.
161pub(crate) fn to_egress_handle(kind: EgressType, room_id: &str, raw: Value) -> EgressHandle {
162    let mut handle = EgressHandle {
163        kind,
164        room_id: room_id.to_string(),
165        id: None,
166        session_id: None,
167        raw: None,
168    };
169
170    if let Some(object) = raw.as_object() {
171        let string = |key: &str| object.get(key).and_then(Value::as_str).map(str::to_string);
172        handle.id = string("recordingId")
173            .or_else(|| string("id"))
174            .or_else(|| string("_id"));
175        handle.session_id = string("sessionId");
176        if let Some(room_id) = string("roomId") {
177            handle.room_id = room_id;
178        }
179    }
180
181    handle.raw = Some(raw);
182    handle
183}
184
185/// What to stop: an [`EgressHandle`], or a bare room id.
186///
187/// ```no_run
188/// # async fn f(client: &videosdk::Client, handle: videosdk::EgressHandle) -> Result<(), videosdk::Error> {
189/// client.hls().stop(&handle).await?;
190/// client.hls().stop("abcd-efgh-ijkl").await?;
191/// # Ok(())
192/// # }
193/// ```
194#[derive(Debug, Clone)]
195pub struct StopTarget {
196    /// The room the egress runs in.
197    pub room_id: String,
198    /// The specific egress to stop, when known.
199    pub id: Option<String>,
200}
201
202impl From<&str> for StopTarget {
203    fn from(room_id: &str) -> Self {
204        Self {
205            room_id: room_id.to_string(),
206            id: None,
207        }
208    }
209}
210
211impl From<String> for StopTarget {
212    fn from(room_id: String) -> Self {
213        Self { room_id, id: None }
214    }
215}
216
217impl From<EgressHandle> for StopTarget {
218    fn from(handle: EgressHandle) -> Self {
219        Self {
220            room_id: handle.room_id,
221            id: handle.id,
222        }
223    }
224}
225
226impl From<&EgressHandle> for StopTarget {
227    fn from(handle: &EgressHandle) -> Self {
228        Self {
229            room_id: handle.room_id.clone(),
230            id: handle.id.clone(),
231        }
232    }
233}
234
235/// The body of a `stop` request: `{roomId, id?}`.
236#[derive(Debug, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub(crate) struct StopWire<'a> {
239    room_id: &'a str,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    id: Option<&'a str>,
242}
243
244impl<'a> From<&'a StopTarget> for StopWire<'a> {
245    fn from(target: &'a StopTarget) -> Self {
246        Self {
247            room_id: &target.room_id,
248            id: target.id.as_deref().filter(|id| !id.is_empty()),
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use serde_json::json;
257
258    fn config_of(composition: Composition, default: Option<LayoutPriority>) -> Value {
259        let mapped = composition_to_config(Some(&composition), default);
260        json!({
261            "config": mapped.config.map(|c| serde_json::to_value(c).unwrap()),
262            "templateUrl": mapped.template_url,
263        })
264    }
265
266    #[test]
267    fn no_composition_maps_to_nothing() {
268        assert_eq!(
269            composition_to_config(None, None),
270            MappedComposition::default()
271        );
272    }
273
274    #[test]
275    fn an_empty_composition_maps_to_nothing() {
276        let mapped = composition_to_config(Some(&Composition::default()), None);
277        assert_eq!(mapped, MappedComposition::default());
278    }
279
280    #[test]
281    fn a_named_layout_is_uppercased() {
282        let mapped = config_of(
283            Composition {
284                layout: Some(CompositionLayout::Spotlight),
285                ..Default::default()
286            },
287            None,
288        );
289        assert_eq!(mapped["config"], json!({"layout": {"type": "SPOTLIGHT"}}));
290        assert_eq!(mapped["templateUrl"], json!(null));
291    }
292
293    #[test]
294    fn a_custom_layout_becomes_a_sibling_template_url_not_a_config_layout() {
295        let mapped = config_of(
296            Composition {
297                layout: Some(CompositionLayout::custom("https://example.com/t.html")),
298                ..Default::default()
299            },
300            None,
301        );
302        assert_eq!(mapped["config"], json!(null));
303        assert_eq!(mapped["templateUrl"], json!("https://example.com/t.html"));
304    }
305
306    #[test]
307    fn a_default_priority_applies_only_when_none_is_given() {
308        let mapped = config_of(
309            Composition {
310                layout: Some(CompositionLayout::Grid),
311                ..Default::default()
312            },
313            Some(LayoutPriority::Speaker),
314        );
315        assert_eq!(mapped["config"]["layout"]["priority"], json!("SPEAKER"));
316
317        let mapped = config_of(
318            Composition {
319                layout: Some(CompositionLayout::Grid),
320                priority: Some(LayoutPriority::Pin),
321                ..Default::default()
322            },
323            Some(LayoutPriority::Speaker),
324        );
325        assert_eq!(mapped["config"]["layout"]["priority"], json!("PIN"));
326    }
327
328    #[test]
329    fn recordings_send_no_priority_by_default() {
330        let mapped = config_of(
331            Composition {
332                layout: Some(CompositionLayout::Grid),
333                ..Default::default()
334            },
335            None,
336        );
337        assert_eq!(mapped["config"], json!({"layout": {"type": "GRID"}}));
338    }
339
340    #[test]
341    fn grid_size_rides_along_with_a_named_layout() {
342        let mapped = config_of(
343            Composition {
344                layout: Some(CompositionLayout::Grid),
345                grid_size: Some(9),
346                ..Default::default()
347            },
348            None,
349        );
350        assert_eq!(
351            mapped["config"]["layout"],
352            json!({"type": "GRID", "gridSize": 9})
353        );
354    }
355
356    /// The backend answers 406 to a non-empty `config` with no `layout`.
357    #[test]
358    fn a_layoutless_config_gets_grid_injected() {
359        let mapped = config_of(
360            Composition {
361                quality: Some(CompositionQuality::High),
362                theme: Some(Theme::Dark),
363                ..Default::default()
364            },
365            None,
366        );
367        assert_eq!(
368            mapped["config"],
369            json!({"layout": {"type": "GRID"}, "theme": "DARK", "quality": "high"})
370        );
371    }
372
373    #[test]
374    fn grid_injection_carries_the_resolved_priority_but_not_grid_size() {
375        let mapped = config_of(
376            Composition {
377                orientation: Some(Orientation::Portrait),
378                grid_size: Some(4),
379                ..Default::default()
380            },
381            Some(LayoutPriority::Speaker),
382        );
383        assert_eq!(
384            mapped["config"]["layout"],
385            json!({"type": "GRID", "priority": "SPEAKER"})
386        );
387    }
388
389    #[test]
390    fn a_custom_template_with_other_options_still_gets_grid_injected() {
391        let mapped = config_of(
392            Composition {
393                layout: Some(CompositionLayout::custom("https://example.com/t.html")),
394                quality: Some(CompositionQuality::Low),
395                ..Default::default()
396            },
397            None,
398        );
399        assert_eq!(
400            mapped["config"],
401            json!({"layout": {"type": "GRID"}, "quality": "low"})
402        );
403        assert_eq!(mapped["templateUrl"], json!("https://example.com/t.html"));
404    }
405
406    /* ------------------------------- handles ------------------------------- */
407
408    #[test]
409    fn a_string_start_response_yields_a_room_keyed_handle() {
410        let handle = to_egress_handle(EgressType::Hls, "r-1", json!("HLS started"));
411        assert_eq!(handle.room_id, "r-1");
412        assert!(handle.id.is_none());
413        assert_eq!(handle.raw, Some(json!("HLS started")));
414    }
415
416    #[test]
417    fn an_object_start_response_yields_ids_in_priority_order() {
418        let handle = to_egress_handle(
419            EgressType::Composite,
420            "r-1",
421            json!({"recordingId": "rec-1", "id": "other", "_id": "another", "sessionId": "s-1"}),
422        );
423        assert_eq!(handle.id.as_deref(), Some("rec-1"));
424        assert_eq!(handle.session_id.as_deref(), Some("s-1"));
425
426        let handle = to_egress_handle(EgressType::Hls, "r-1", json!({"id": "h-1"}));
427        assert_eq!(handle.id.as_deref(), Some("h-1"));
428
429        let handle = to_egress_handle(EgressType::Hls, "r-1", json!({"_id": "h-2"}));
430        assert_eq!(handle.id.as_deref(), Some("h-2"));
431    }
432
433    #[test]
434    fn a_start_response_room_id_overrides_the_requested_one() {
435        let handle = to_egress_handle(
436            EgressType::Recording,
437            "requested",
438            json!({"roomId": "real"}),
439        );
440        assert_eq!(handle.room_id, "real");
441    }
442
443    #[test]
444    fn stop_targets_accept_handles_and_bare_room_ids() {
445        let handle = to_egress_handle(EgressType::Recording, "r-1", json!({"id": "e-1"}));
446        let target: StopTarget = (&handle).into();
447        assert_eq!(target.room_id, "r-1");
448        assert_eq!(target.id.as_deref(), Some("e-1"));
449
450        let target: StopTarget = "r-2".into();
451        assert_eq!(target.room_id, "r-2");
452        assert!(target.id.is_none());
453    }
454
455    #[test]
456    fn stop_wire_omits_an_absent_or_empty_id() {
457        let target = StopTarget {
458            room_id: "r-1".into(),
459            id: None,
460        };
461        assert_eq!(
462            serde_json::to_value(StopWire::from(&target)).unwrap(),
463            json!({"roomId": "r-1"})
464        );
465
466        let target = StopTarget {
467            room_id: "r-1".into(),
468            id: Some(String::new()),
469        };
470        assert_eq!(
471            serde_json::to_value(StopWire::from(&target)).unwrap(),
472            json!({"roomId": "r-1"})
473        );
474
475        let target = StopTarget {
476            room_id: "r-1".into(),
477            id: Some("e-1".into()),
478        };
479        assert_eq!(
480            serde_json::to_value(StopWire::from(&target)).unwrap(),
481            json!({"roomId": "r-1", "id": "e-1"})
482        );
483    }
484}