//! freeq's AV media plane, for callers that are not Rust. //! //! sleek reaches its calls by calling Rust from Rust. A client written in //! anything else cannot, and this is not a plane to write twice: MoQ over //! QUIC, Opus, H.264, camera capture and the SFU's own dial rules come to some //! three thousand lines that would say exactly the same thing again in another //! language, and be wrong in different places. //! //! So it was lifted out of sleek to here, and what crosses the boundary is //! only what jolt can hold: integers, doubles, borrowed UTF-8 strings, and one //! borrowed pixel pointer. //! //! # What is not here //! //! **Signaling.** A freeq call is opened, joined and left over IRC TAGMSGs — //! `+freeq.at/av-start` and its siblings — and the server answers with //! `+freeq.at/av-state`. Any client that can speak IRC already has everything //! it needs for that, in whatever language it speaks IRC in; crossing an FFI //! boundary to send a TAGMSG would be worse than not. This library begins once //! the session id and the SFU token are known, and ends when the call does. //! //! # The shape of it //! //! **Nothing calls back.** Status arrives through [`joltmoq_poll_status`], //! video through [`joltmoq_frame_poll`], both drained from whatever thread the //! caller paints on — see [`jolt_abi`] for why. //! //! **One call at a time.** There is one microphone, so there is one session, //! held in a global. A handle would imply a second call could run beside the //! first, which is not something the hardware or the person would enjoy. //! //! **Frames are borrowed, not copied.** [`joltmoq_frame_rgba`] hands out a //! pointer into the decoder's own buffer, good until the next poll. A frame is //! a megabyte or two; copying it out so the caller can hand it straight to a //! texture upload would be two copies a frame to no end. pub mod av; pub mod av_media; mod v4l2cam; use std::collections::HashMap; use std::ffi::{c_char, c_int}; use std::sync::{Mutex, OnceLock}; use jolt_abi::{borrowed, empty_str, guard, preference, Scratch}; use av::{RgbaVideoFrame, VideoFrameStore}; use av_media::{AvMediaConfig, AvMediaSession, AvMediaUpdate}; /// One scratch per family of string-returning calls, so that asking for the /// video keys does not invalidate a device list the caller is still reading. static STATUS_TEXT: Scratch = Scratch::new(); static FRAME_KEY: Scratch = Scratch::new(); static VIDEO_KEYS: Scratch = Scratch::new(); static DEVICES: Scratch = Scratch::new(); static DIAL: Scratch = Scratch::new(); // ── The one session ───────────────────────────────────────────────────────── /// Everything a live call holds on this side. #[derive(Default)] struct Slot { session: Option, /// Status updates from the media task, waiting to be polled. status: Vec, /// The status most recently handed out, whose fields the accessors read. current: Option, /// The live session's frame store, and the generation last handed out per /// feed — which is how a poll knows what is new. video: Option, seen: HashMap, /// The frame most recently handed out. Held so that the pixel pointer the /// caller was given stays alive until the next poll replaces it. frame: Option<(String, RgbaVideoFrame)>, /// A tokio runtime of our own: the caller has no reactor to lend us. Made /// once and kept, since building one per call would tear worker threads /// down and up again on every join. runtime: Option, } #[derive(Clone)] enum Status { Live { has_camera: bool, has_mic: bool }, Ended, Failed(String), } fn session_slot() -> &'static Mutex { static SLOT: OnceLock> = OnceLock::new(); SLOT.get_or_init(|| Mutex::new(Slot::default())) } /// Run `f` against the one session, catching panics on the way out. /// /// `fallback` answers both a panic and a poisoned lock, and is deliberately /// taken by value rather than requiring `Copy`: the answer is sometimes a /// `String`. The `Option` is how one value serves both arms without being /// moved twice. fn with_slot(fallback: R, f: impl FnOnce(&mut Slot) -> R) -> R { guard(None, || match session_slot().lock() { Ok(mut slot) => Some(f(&mut slot)), Err(_) => None, }) .unwrap_or(fallback) } // ── Lifecycle ─────────────────────────────────────────────────────────────── /// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice. /// /// Worth calling first while a call refuses to connect: the media plane says a /// great deal about why, and says none of it otherwise. #[no_mangle] pub extern "C" fn joltmoq_init_logging() { guard((), || { let _ = env_logger::try_init(); }) } /// Join the call at `sfu_url` as `nick`, answering 1 when the media task /// started. /// /// `sfu_url` is what [`joltmoq_sfu_url`] built, `session_id` is the id the /// server broadcast in `+freeq.at/av-id`, and `instance` is the per-device id /// the caller put in its own `av-join` — two devices signed in as the same /// person need different ones or their broadcast paths collide and each /// unpublishes the other. /// /// The rest are pre-call preferences: `muted`, `speaker_muted` and `camera` as /// 0 or 1, then three device preferences, each an empty string for "whatever /// the system uses". /// /// This returns as soon as the task is spawned. Connecting takes a moment, and /// it is [`joltmoq_poll_status`] that says whether it worked. Starting a second /// call while one is live is refused; stop the first. /// /// # Safety /// Every pointer is null or a NUL-terminated UTF-8 string. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn joltmoq_start( sfu_url: *const c_char, session_id: *const c_char, nick: *const c_char, instance: *const c_char, muted: c_int, speaker_muted: c_int, camera: c_int, camera_id: *const c_char, mic_id: *const c_char, speaker_id: *const c_char, ) -> c_int { let sfu_url = borrowed(sfu_url); let session_id = borrowed(session_id); let nick = borrowed(nick); let instance = borrowed(instance); let camera_id = preference(borrowed(camera_id)); let mic_id = preference(borrowed(mic_id)); let speaker_id = preference(borrowed(speaker_id)); with_slot(0, |slot| { if slot.session.is_some() { log::warn!("joltmoq: a call is already live; stop it first"); return 0; } let Ok(url) = url::Url::parse(&sfu_url) else { log::warn!("joltmoq: not a URL: {sfu_url}"); return 0; }; let runtime = match slot.runtime.take() { Some(runtime) => runtime, None => match tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_name("joltmoq") .build() { Ok(runtime) => runtime, Err(e) => { log::error!("joltmoq: no runtime: {e}"); return 0; } }, }; let config = AvMediaConfig { sfu_url: url, session_id, nick, instance, muted: muted != 0, speaker_muted: speaker_muted != 0, camera_enabled: camera != 0, camera_id, mic_id, speaker_id, }; // `AvMediaSession::start` spawns onto the ambient runtime, so it has to // be entered. The status closure it takes runs on a worker thread, // which is why all it does is push onto a queue the caller drains. let _entered = runtime.enter(); let session = AvMediaSession::start(config, |update| { let status = match update { AvMediaUpdate::Live { has_camera, has_mic, .. } => Status::Live { has_camera, has_mic, }, AvMediaUpdate::Ended => Status::Ended, AvMediaUpdate::Failed(e) => Status::Failed(e), }; if let Ok(mut slot) = session_slot().lock() { slot.status.push(status); } }); slot.video = Some(session.video.clone()); slot.session = Some(session); slot.runtime = Some(runtime); slot.seen.clear(); slot.frame = None; 1 }) } /// Leave the call: unpublish, drop the devices, forget the frames. /// /// Waits briefly for the media task to tear MoQ down rather than aborting it /// outright. An abandoned broadcast lingers on the SFU and peers subscribe to /// it, so they see someone present and hear silence. #[no_mangle] pub extern "C" fn joltmoq_stop() { with_slot((), |slot| { let Some(mut session) = slot.session.take() else { return; }; match slot.runtime.as_ref() { Some(runtime) => { runtime.block_on(session.stop_and_wait(std::time::Duration::from_secs(2))) } None => session.stop(), } slot.video = None; slot.seen.clear(); slot.frame = None; }) } /// 1 while a call is live on this side. #[no_mangle] pub extern "C" fn joltmoq_is_live() -> c_int { with_slot(0, |slot| slot.session.is_some() as c_int) } // ── Controls ──────────────────────────────────────────────────────────────── /// Mute or unmute the microphone. Peers stop hearing you; you still hear them. #[no_mangle] pub extern "C" fn joltmoq_set_muted(muted: c_int) { with_slot((), |slot| { if let Some(s) = &slot.session { s.set_muted(muted != 0); } }) } /// Mute or unmute remote audio — deafen. Deliberately not the same control as /// [`joltmoq_set_muted`]: peers still hear you if the microphone is open. #[no_mangle] pub extern "C" fn joltmoq_set_speaker_muted(muted: c_int) { with_slot((), |slot| { if let Some(s) = &slot.session { s.set_speaker_muted(muted != 0); } }) } /// Start or stop publishing camera. The device is only held while publishing, /// so turning it off gives the hardware back to the rest of the machine. #[no_mangle] pub extern "C" fn joltmoq_set_camera(enabled: c_int) { with_slot((), |slot| { if let Some(s) = &slot.session { s.set_camera_enabled(enabled != 0); } }) } /// Re-open the camera by id; an empty string means the first available. /// /// # Safety /// `id` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn joltmoq_set_camera_device(id: *const c_char) { let id = preference(borrowed(id)); with_slot((), |slot| { if let Some(s) = &slot.session { s.set_camera_device(id); } }) } /// Switch microphone by name; an empty string means the system default. /// /// # Safety /// `id` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn joltmoq_set_mic_device(id: *const c_char) { let id = preference(borrowed(id)); with_slot((), |slot| { if let Some(s) = &slot.session { s.set_mic_device(id); } }) } /// Switch speaker by name; an empty string means the system default. /// /// # Safety /// `id` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn joltmoq_set_speaker_device(id: *const c_char) { let id = preference(borrowed(id)); with_slot((), |slot| { if let Some(s) = &slot.session { s.set_speaker_device(id); } }) } /// The live microphone envelope, 0.0 to 1.0 — what a level meter draws. 0 when /// no call is up. #[no_mangle] pub extern "C" fn joltmoq_mic_level() -> f64 { with_slot(0.0, |slot| { slot.session .as_ref() .map_or(0.0, |s| s.mic_level.get() as f64) }) } // ── Status ────────────────────────────────────────────────────────────────── /// Nothing waiting. pub const JOLTMOQ_STATUS_NONE: c_int = 0; /// Connected and publishing. pub const JOLTMOQ_STATUS_LIVE: c_int = 1; /// The session ended cleanly. pub const JOLTMOQ_STATUS_ENDED: c_int = 2; /// Connect or runtime failure; [`joltmoq_status_text`] says what. pub const JOLTMOQ_STATUS_FAILED: c_int = 3; /// Dequeue one status update, or 0 when there is none. /// /// The media task produces these on its own threads; this hands them to the /// caller's. Drain it until it answers 0 wherever the caller polls — beside a /// repaint is the natural place. Missing an update means a call that is up /// still looks like it is connecting. #[no_mangle] pub extern "C" fn joltmoq_poll_status() -> c_int { with_slot(JOLTMOQ_STATUS_NONE, |slot| { if slot.status.is_empty() { slot.current = None; return JOLTMOQ_STATUS_NONE; } let status = slot.status.remove(0); let code = match &status { Status::Live { .. } => JOLTMOQ_STATUS_LIVE, Status::Ended => JOLTMOQ_STATUS_ENDED, Status::Failed(_) => JOLTMOQ_STATUS_FAILED, }; slot.current = Some(status); code }) } /// Why the last polled status failed; the empty string for any other status. /// /// # Safety /// The returned pointer is valid until the next call to this function. #[no_mangle] pub extern "C" fn joltmoq_status_text() -> *const c_char { let text = with_slot(String::new(), |slot| match &slot.current { Some(Status::Failed(e)) => e.clone(), _ => String::new(), }); if text.is_empty() { return empty_str(); } STATUS_TEXT.lend(text) } /// 1 when the last polled `live` status found a capture device for this call. /// /// A call can be live with no camera at all, so a caller shows a camera control /// only when there is something for it to turn on. #[no_mangle] pub extern "C" fn joltmoq_status_has_camera() -> c_int { with_slot(0, |slot| match &slot.current { Some(Status::Live { has_camera, .. }) => *has_camera as c_int, _ => 0, }) } /// 1 when the last polled `live` status had a real microphone feeding the /// outbound track. 0 means listen-only: audio is still published, as silence. #[no_mangle] pub extern "C" fn joltmoq_status_has_mic() -> c_int { with_slot(0, |slot| match &slot.current { Some(Status::Live { has_mic, .. }) => *has_mic as c_int, _ => 0, }) } // ── Video ─────────────────────────────────────────────────────────────────── /// Advance to the next feed carrying a frame the caller has not been given, /// answering 1 while there was one. /// /// Loop until it answers 0, reading [`joltmoq_frame_key`], /// [`joltmoq_frame_width`], [`joltmoq_frame_height`] and /// [`joltmoq_frame_rgba`] for each and handing those to whatever paints. Only /// what changed comes over: a participant sitting still costs nothing, and a /// decoder running ahead of the window is coalesced to its newest frame rather /// than queued behind stale ones. /// /// The key is the participant's nick, or `__local__` for the self-view. #[no_mangle] pub extern "C" fn joltmoq_frame_poll() -> c_int { with_slot(0, |slot| { slot.frame = None; let Some(video) = slot.video.clone() else { return 0; }; let snapshot = video.snapshot(); for (key, frame) in &snapshot { if slot.seen.get(key) == Some(&frame.gen) { continue; } slot.seen.insert(key.clone(), frame.gen); slot.frame = Some((key.clone(), frame.clone())); return 1; } // Nothing new. Forget the keys nobody is publishing any more, so that // if they come back the first frame of the new stream reads as new // rather than as one already seen. slot.seen .retain(|k, _| snapshot.iter().any(|(live, _)| live == k)); 0 }) } /// Whose picture the last polled frame is: a nick, or `__local__`. /// /// # Safety /// The returned pointer is valid until the next call to this function. #[no_mangle] pub extern "C" fn joltmoq_frame_key() -> *const c_char { let key = with_slot(String::new(), |slot| { slot.frame .as_ref() .map_or(String::new(), |(k, _)| k.clone()) }); if key.is_empty() { return empty_str(); } FRAME_KEY.lend(key) } /// The last polled frame's width in pixels, or 0. #[no_mangle] pub extern "C" fn joltmoq_frame_width() -> c_int { with_slot(0, |slot| { slot.frame.as_ref().map_or(0, |(_, f)| f.width as c_int) }) } /// The last polled frame's height in pixels, or 0. #[no_mangle] pub extern "C" fn joltmoq_frame_height() -> c_int { with_slot(0, |slot| { slot.frame.as_ref().map_or(0, |(_, f)| f.height as c_int) }) } /// The last polled frame's pixels: `width * height * 4` bytes, row-major, 8 /// bits a channel, un-premultiplied and opaque. Null when there is no frame. /// /// **Borrowed, and only until the next [`joltmoq_frame_poll`].** That is the /// point of polling: a frame is a megabyte or two, and copying it into the /// caller's memory so it can hand it straight to a texture upload would be two /// copies a frame for nothing. Upload it, then poll again. /// /// # Safety /// Valid for reads of `width * height * 4` bytes until the next call to /// [`joltmoq_frame_poll`] or [`joltmoq_stop`]. #[no_mangle] pub extern "C" fn joltmoq_frame_rgba() -> *const u8 { with_slot(std::ptr::null(), |slot| match &slot.frame { Some((_, frame)) => frame.rgba.as_ptr(), None => std::ptr::null(), }) } /// Everyone whose picture the call is currently carrying, one key a line. /// /// A caller painting a tile per feed uses this to notice a tile it should stop /// painting: someone who left stops appearing here, while their last frame /// would otherwise hang on the wall for the rest of the call. /// /// # Safety /// The returned pointer is valid until the next call to this function. #[no_mangle] pub extern "C" fn joltmoq_video_keys() -> *const c_char { let keys = with_slot(String::new(), |slot| match &slot.video { Some(video) => { let mut keys: Vec = video.snapshot().into_iter().map(|(k, _)| k).collect(); keys.sort(); keys.join("\n") } None => String::new(), }); if keys.is_empty() { return empty_str(); } VIDEO_KEYS.lend(keys) } // ── Devices ───────────────────────────────────────────────────────────────── /// The cameras, one a line, each `id\tname\tdefault`. /// /// Enumerating opens nothing, so this is safe to ask before a call and during /// one. Empty on a machine with no camera, which is a normal answer. /// /// # Safety /// The returned pointer is valid until the next device-listing call. #[no_mangle] pub extern "C" fn joltmoq_cameras() -> *const c_char { lend_devices(guard(Vec::new(), av_media::list_cameras)) } /// The microphones, one a line, each `id\tname\tdefault`. /// /// # Safety /// The returned pointer is valid until the next device-listing call. #[no_mangle] pub extern "C" fn joltmoq_microphones() -> *const c_char { lend_devices(guard(Vec::new(), av_media::list_microphones)) } /// The speakers, one a line, each `id\tname\tdefault`. /// /// # Safety /// The returned pointer is valid until the next device-listing call. #[no_mangle] pub extern "C" fn joltmoq_speakers() -> *const c_char { lend_devices(guard(Vec::new(), av_media::list_speakers)) } fn lend_devices(list: Vec) -> *const c_char { let rendered = devices(list); if rendered.is_empty() { return empty_str(); } DEVICES.lend(rendered) } /// A device list as lines of `id\tname\tdefault`, where the last column is 1 /// for the system default and 0 otherwise. /// /// Tab and newline are the delimiters because a device name may contain /// anything else — "EMEET SmartCam C960, Mono" has spaces and a comma in it, /// and a caller splitting on those gets nonsense. A name that somehow holds a /// delimiter has it replaced rather than being allowed to forge a row. /// /// The default is worth a column of its own: a picker that cannot mark it has /// to guess, and "Default" is not reliably the first entry or a name anyone /// can match on. fn devices(list: Vec) -> String { list.into_iter() .map(|d| { format!( "{}\t{}\t{}", d.id.replace(['\t', '\n'], " "), d.name.replace(['\t', '\n'], " "), d.is_default as u8 ) }) .collect::>() .join("\n") } // ── Dialling ──────────────────────────────────────────────────────────────── /// The SFU URL to hand [`joltmoq_start`], built from the IRC server the caller /// is connected to and the JWT the server minted in `+freeq.at/av-token`. /// /// `server` is whatever the client knows the server as — `irc.freeq.at:6697`, /// `wss://irc.freeq.at/irc` — and both become `https://irc.freeq.at/av/moq`. /// `jwt` and `instance` may be empty. The empty string comes back when the /// server is not something a URL can be made of. /// /// This lives here rather than in the caller because the rules are fiddly and /// already tested on this side: which scheme maps to which, where the port /// goes, and what to do with a path that was there. /// /// # Safety /// Every pointer is null or a NUL-terminated UTF-8 string; the returned pointer /// is valid until the next call to this function. #[no_mangle] pub unsafe extern "C" fn joltmoq_sfu_url( server: *const c_char, jwt: *const c_char, instance: *const c_char, ) -> *const c_char { let server = borrowed(server); let jwt = preference(borrowed(jwt)); let instance = preference(borrowed(instance)); let url = guard(String::new(), || { av::sfu_moq_dial_url(&server, jwt.as_deref(), instance.as_deref()) .map(|u| u.to_string()) .unwrap_or_default() }); if url.is_empty() { return empty_str(); } DIAL.lend(url) } /// 1 when dialling this server is worth attempting. /// /// A remote SFU with no token accepts the connection and closes it, and /// moq-lite then retries in a tight loop that looks, from the outside, exactly /// like a hang. Asking first is cheaper than explaining that. /// /// # Safety /// Both pointers are null or NUL-terminated UTF-8 strings. #[no_mangle] pub unsafe extern "C" fn joltmoq_can_dial(server: *const c_char, jwt: *const c_char) -> c_int { let server = borrowed(server); let jwt = preference(borrowed(jwt)); guard(0, || av::can_dial_sfu(&server, jwt.as_deref()) as c_int) } /// A per-device call instance id — eight hex characters. /// /// Two devices signed in as the same person need different ones, or their MoQ /// broadcast paths collide and each unpublishes the other. The caller puts this /// in its own `+freeq.at/av-instance` tag and hands the same one to /// [`joltmoq_start`]. /// /// # Safety /// The returned pointer is valid until the next call to this function. #[no_mangle] pub extern "C" fn joltmoq_new_instance() -> *const c_char { DIAL.lend(guard("00000000".to_owned(), || { format!("{:08x}", rand::random::()) })) } #[cfg(test)] mod tests { use super::*; use std::ffi::{CStr, CString}; fn read(ptr: *const c_char) -> String { unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned() } #[test] fn a_device_list_is_tab_and_newline_delimited() { let list = vec![ av_media::MediaDevice { id: "/dev/video0".to_owned(), name: "EMEET SmartCam C960, Mono".to_owned(), is_default: true, }, av_media::MediaDevice { id: "/dev/video2".to_owned(), name: "Integrated".to_owned(), is_default: false, }, ]; assert_eq!( devices(list), "/dev/video0\tEMEET SmartCam C960, Mono\t1\n/dev/video2\tIntegrated\t0" ); } #[test] fn a_name_holding_a_delimiter_cannot_forge_a_row() { let list = vec![av_media::MediaDevice { id: "a\tb".to_owned(), name: "two\nlines".to_owned(), is_default: false, }]; let rendered = devices(list); assert_eq!(rendered, "a b\ttwo lines\t0"); assert_eq!(rendered.lines().count(), 1); } #[test] fn the_sfu_url_is_built_from_whatever_the_server_is_known_as() { let jwt = CString::new("tok.jwt.value").unwrap(); let inst = CString::new("abcd1234").unwrap(); for known_as in ["irc.freeq.at:6697", "wss://irc.freeq.at/irc"] { let server = CString::new(known_as).unwrap(); let url = read(unsafe { joltmoq_sfu_url(server.as_ptr(), jwt.as_ptr(), inst.as_ptr()) }); assert!(url.starts_with("https://irc.freeq.at/av/moq"), "{url}"); assert!(url.contains("inst=abcd1234"), "{url}"); } } #[test] fn a_server_that_is_not_a_url_answers_the_empty_string() { let server = CString::new(" ").unwrap(); let url = read(unsafe { joltmoq_sfu_url(server.as_ptr(), std::ptr::null(), std::ptr::null()) }); assert_eq!(url, ""); } #[test] fn a_remote_sfu_without_a_token_is_not_worth_dialling() { let remote = CString::new("wss://chat.example.com").unwrap(); let local = CString::new("ws://localhost:4443").unwrap(); let jwt = CString::new("tok").unwrap(); assert_eq!( unsafe { joltmoq_can_dial(remote.as_ptr(), std::ptr::null()) }, 0 ); assert_eq!(unsafe { joltmoq_can_dial(remote.as_ptr(), jwt.as_ptr()) }, 1); assert_eq!( unsafe { joltmoq_can_dial(local.as_ptr(), std::ptr::null()) }, 1 ); } #[test] fn an_instance_id_is_eight_hex_characters_and_differs() { let one = read(joltmoq_new_instance()); let two = read(joltmoq_new_instance()); assert_eq!(one.len(), 8); assert!(one.chars().all(|c| c.is_ascii_hexdigit()), "{one}"); assert_ne!(one, two, "two devices would collide on the SFU"); } #[test] fn nothing_is_live_before_a_call_and_every_poll_answers_empty() { assert_eq!(joltmoq_is_live(), 0); assert_eq!(joltmoq_poll_status(), JOLTMOQ_STATUS_NONE); assert_eq!(read(joltmoq_status_text()), ""); assert_eq!(joltmoq_frame_poll(), 0); assert!(joltmoq_frame_rgba().is_null()); assert_eq!(joltmoq_frame_width(), 0); assert_eq!(read(joltmoq_frame_key()), ""); assert_eq!(read(joltmoq_video_keys()), ""); } #[test] fn a_control_with_no_call_under_it_is_inert() { // A UI that sends a mute as the call is ending must not take the // process with it. joltmoq_set_muted(1); joltmoq_set_speaker_muted(1); joltmoq_set_camera(1); unsafe { joltmoq_set_mic_device(std::ptr::null()) }; joltmoq_stop(); assert_eq!(joltmoq_mic_level(), 0.0); assert_eq!(joltmoq_is_live(), 0); } #[test] fn a_frame_poll_yields_each_feed_once_until_it_changes() { let video = VideoFrameStore::new(); video.set("nandi", 1, 1, std::sync::Arc::from(vec![9u8; 4])); if let Ok(mut slot) = session_slot().lock() { slot.video = Some(video.clone()); slot.seen.clear(); } assert_eq!(joltmoq_frame_poll(), 1); assert_eq!(read(joltmoq_frame_key()), "nandi"); assert_eq!(joltmoq_frame_width(), 1); // Same frame: nothing new to hand over. assert_eq!(joltmoq_frame_poll(), 0); // A new frame under the same key is new again. video.set("nandi", 1, 1, std::sync::Arc::from(vec![3u8; 4])); assert_eq!(joltmoq_frame_poll(), 1); assert_eq!(read(joltmoq_frame_key()), "nandi"); assert_eq!(read(joltmoq_video_keys()), "nandi"); if let Ok(mut slot) = session_slot().lock() { slot.video = None; slot.seen.clear(); slot.frame = None; } } }