From 61e58fe9860ae752da5f8db837765dc1954e6aa0 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 17 Oct 2026 09:24:07 -0400 Subject: [PATCH 1/9] wai: live_compose, the layout function, the mixer, the descriptor, the set and the receipt (WIP) Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/live_compose.rs | 920 ++++++++++++++++++++++++++ wai/wai-rs/src/live_compose/layout.rs | 748 +++++++++++++++++++++ 2 files changed, 1668 insertions(+) create mode 100644 wai/wai-rs/src/live_compose.rs create mode 100644 wai/wai-rs/src/live_compose/layout.rs diff --git a/wai/wai-rs/src/live_compose.rs b/wai/wai-rs/src/live_compose.rs new file mode 100644 index 000000000..640a59db0 --- /dev/null +++ b/wai/wai-rs/src/live_compose.rs @@ -0,0 +1,920 @@ +//! Live composition — `wai.live.compose` ([extensions/live-compose.md]). +//! +//! A room's composition is an integer function. A signed **composition descriptor** +//! (`WCD1`), by the room authority or a principal whose grant has the compose power +//! (live-rooms §3.3), fixes the canvas, the background, the layout (a grid, or a grid with +//! a picture-in-picture window), the attribute keys it reads and its limits. The +//! **participant set** at a room-clock instant is derived from the room's lifecycle +//! replay ([`crate::live_room`]): who publishes, each one's video and audio source and its +//! dimensions, the attributes of its grant in effect, and its join order. The layout and +//! the voices of the mix are then a pure function of the two ([`layout::layout`]), and the +//! mix a pure function of decoded PCM ([`layout::mix`]), so every sink composes the same +//! rectangles locally, and a server compositor that publishes a composed track signs a +//! **composition receipt** (`WCR1`) that anyone can check against the same function. +//! +//! What this module implements, by section of the extension: +//! +//! - §3: the descriptor, read, its canvas limits and field rules, its digest and +//! signature, and who may sign it; +//! - §4: the room state at an instant, the participant set, the attributes the descriptor +//! reads, the order, and the set's digest; +//! - §5, §6: the layout and the mixer, in [`layout`], which uses nothing beyond `core`, +//! `Vec` and `String`; +//! - §8: the receipt, read, signed, and verified against the room and the descriptor. +//! +//! The corpus `live-conformance/compose/` is written by [`corpus::generate`] and checked +//! by [`corpus::verify`] here, and by `live-conformance/live_compose_verify.py`, which +//! shares no code with this module. +//! +//! [extensions/live-compose.md]: ../../extensions/live-compose.md + +use std::collections::{BTreeMap, BTreeSet}; + +use ed25519_dalek::{Signer, SigningKey}; +use sha2::{Digest, Sha256}; + +use crate::live_events::{self as events, anchor_track, kind, Event, Payload}; +use crate::live_room::{ + self as room, layering, media, media_name, power, render_namespace, track_name, track_namespace, verify_room, At, Descriptor, Grant, Held, Owner, RoomOutcome, RoomRun, + RoomState, +}; + +pub mod corpus; +pub mod layout; + +use layout::{Entrant, Layout, Params, Refusal}; + +/// The record this extension registers. +pub const CAPABILITY: &str = "wai.live.compose"; +/// The magic of a composition descriptor (§3.1). +pub const DESCRIPTOR_MAGIC: [u8; 4] = *b"WCD1"; +/// The magic of a composition receipt (§8.1). +pub const RECEIPT_MAGIC: [u8; 4] = *b"WCR1"; +/// The version of both records in this revision. +pub const RECORD_VERSION: u8 = 1; +/// The domain a descriptor's signature is made over (§3.2). +pub const DESCRIPTOR_DOMAIN: &[u8] = b"wai:live-compose\x01"; +/// The domain a receipt's signature is made over (§8.1). +pub const RECEIPT_DOMAIN: &[u8] = b"wai:live-compose-receipt\x01"; +/// The domain of a participant set's digest (§4.5). +pub const SET_DOMAIN: &[u8] = b"wai:live-compose-set\x01"; +/// A descriptor's `compositor` when no server composes (§3.1, §7). +pub const NO_COMPOSITOR: [u8; 32] = [0; 32]; + +// --------------------------------------------------------------------------------------- +// §10 Reasons +// --------------------------------------------------------------------------------------- + +/// Why a composition, a mix or a receipt is refused (§10). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Reason { + /// A code of live-rooms §11, or of live-events §9 through it. + Room(room::Reason), + CanvasOutOfRange, + ComposeInvalid, + ComposeNotPermitted, + NotYetEffective, + LifecycleIncomplete, + AttrMalformed, + MixInputMalformed, + ReceiptInvalid, + DescriptorMismatch, + CompositorNotPermitted, + OutputNotPublished, + StateMismatch, + InputsMismatch, +} + +impl From for Reason { + fn from(r: room::Reason) -> Self { + Reason::Room(r) + } +} + +impl From for Reason { + fn from(r: events::Reason) -> Self { + Reason::Room(room::Reason::Events(r)) + } +} + +impl From for Reason { + fn from(r: Refusal) -> Self { + match r { + Refusal::CanvasOutOfRange => Reason::CanvasOutOfRange, + Refusal::ComposeInvalid => Reason::ComposeInvalid, + Refusal::AttrMalformed => Reason::AttrMalformed, + Refusal::MixInputMalformed => Reason::MixInputMalformed, + } + } +} + +const TRUNCATED: Reason = Reason::Room(room::Reason::Events(events::Reason::Truncated)); +const MALFORMED: Reason = Reason::Room(room::Reason::Events(events::Reason::Malformed)); +const UNKNOWN_VERSION: Reason = Reason::Room(room::Reason::Events(events::Reason::UnknownVersion)); +const WRONG_ROOM: Reason = Reason::Room(room::Reason::Events(events::Reason::WrongRoom)); + +impl Reason { + /// The codes this extension adds, in §10's order. + pub const ALL: [Reason; 13] = [ + Reason::CanvasOutOfRange, + Reason::ComposeInvalid, + Reason::ComposeNotPermitted, + Reason::NotYetEffective, + Reason::LifecycleIncomplete, + Reason::AttrMalformed, + Reason::MixInputMalformed, + Reason::ReceiptInvalid, + Reason::DescriptorMismatch, + Reason::CompositorNotPermitted, + Reason::OutputNotPublished, + Reason::StateMismatch, + Reason::InputsMismatch, + ]; + + /// The reason's code. + pub fn code(self) -> &'static str { + match self { + Reason::Room(r) => r.code(), + Reason::CanvasOutOfRange => "canvas_out_of_range", + Reason::ComposeInvalid => "compose_invalid", + Reason::ComposeNotPermitted => "compose_not_permitted", + Reason::NotYetEffective => "not_yet_effective", + Reason::LifecycleIncomplete => "lifecycle_incomplete", + Reason::AttrMalformed => "attr_malformed", + Reason::MixInputMalformed => "mix_input_malformed", + Reason::ReceiptInvalid => "receipt_invalid", + Reason::DescriptorMismatch => "descriptor_mismatch", + Reason::CompositorNotPermitted => "compositor_not_permitted", + Reason::OutputNotPublished => "output_not_published", + Reason::StateMismatch => "state_mismatch", + Reason::InputsMismatch => "inputs_mismatch", + } + } +} + +impl std::fmt::Display for Reason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for Reason {} + +/// Where a verification stopped (§9). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Place { + /// The room: where live-rooms §9 refused it, or none for the room as a whole. + Room(Option), + Descriptor, + Receipt, + /// The participant set or the layout at this instant. + Set(u64), +} + +// --------------------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------------------- + +fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +/// A cursor; running out is `truncated`. +struct Rd<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8], Reason> { + let e = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or(TRUNCATED)?; + let s = &self.b[self.p..e]; + self.p = e; + Ok(s) + } + fn arr(&mut self) -> Result<[u8; N], Reason> { + Ok(self.take(N)?.try_into().expect("N bytes")) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.arr()?)) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.arr()?)) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.arr()?)) + } + /// A short field: a u8 length and that many bytes. + fn short(&mut self) -> Result<&'a [u8], Reason> { + let n = self.u8()? as usize; + self.take(n) + } +} + +/// live-events §3.3's first two steps, with `magic` for `WEV1`. +fn read_head(b: &[u8], magic: &[u8; 4]) -> Result<(), Reason> { + if b.len() < 4 { + return Err(TRUNCATED); + } + if b[..4] != *magic { + return Err(MALFORMED); + } + if b.len() < 5 { + return Err(TRUNCATED); + } + if b[4] != RECORD_VERSION { + return Err(UNKNOWN_VERSION); + } + Ok(()) +} + +fn verify_sig(key: &[u8; 32], domain: &[u8], body: &[u8], sig: &[u8; 64]) -> Result<(), Reason> { + let mut m = domain.to_vec(); + m.extend_from_slice(body); + crate::sig::verify_strict(key, &m, sig).map_err(|e| match e { + crate::sig::SigError::BadKey => events::Reason::BadKey.into(), + crate::sig::SigError::BadSignature => events::Reason::BadSignature.into(), + }) +} + +fn put_short(o: &mut Vec, s: &[u8]) { + assert!(s.len() <= u8::MAX as usize, "a short field is at most 255 bytes"); + o.push(s.len() as u8); + o.extend_from_slice(s); +} + +fn u16_of(v: u32) -> [u8; 2] { + u16::try_from(v).expect("a field of 16 bits").to_le_bytes() +} + +// --------------------------------------------------------------------------------------- +// §3 The composition descriptor +// --------------------------------------------------------------------------------------- + +/// A composition descriptor (§3.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Compose { + /// The room id (live-rooms §3.2). + pub room: [u8; 32], + /// The key that signs it: the room authority, or a principal with the compose power. + pub signer: [u8; 32], + /// The room-clock instant from which it applies. + pub effective: u64, + /// The principal that may publish the composed tracks; [`NO_COMPOSITOR`] for none. + pub compositor: [u8; 32], + /// sRGB, 8 bits each of red, green and blue. + pub background: [u8; 3], + /// The highest layer composed (§4.2). + pub layer: u8, + /// Everything else, as the layout reads it. + pub params: Params, + pub sig: [u8; 64], +} + +impl Compose { + /// The descriptor without `sig` (§3.1). + pub fn body(&self) -> Vec { + let p = &self.params; + let mut o = Vec::with_capacity(256); + o.extend_from_slice(&DESCRIPTOR_MAGIC); + o.push(RECORD_VERSION); + o.extend_from_slice(&self.room); + o.extend_from_slice(&self.signer); + o.extend_from_slice(&self.effective.to_le_bytes()); + o.extend_from_slice(&self.compositor); + o.extend_from_slice(&u16_of(p.width)); + o.extend_from_slice(&u16_of(p.height)); + o.push(p.fps); + o.extend_from_slice(&self.background); + o.push(self.layer); + o.push(p.layout); + o.extend_from_slice(&u16_of(p.gap)); + o.push(p.aspect); + o.push(p.fill); + put_short(&mut o, p.featured_attr.as_bytes()); + put_short(&mut o, p.order_attr.as_bytes()); + o.push(p.omit_stopped as u8); + put_short(&mut o, p.pip_attr.as_bytes()); + o.push(p.corner); + o.extend_from_slice(&u16_of(p.pip_width)); + o.extend_from_slice(&u16_of(p.pip_height)); + o.extend_from_slice(&u16_of(p.offset)); + o.push(p.behaviour); + o.push(p.channels); + o.extend_from_slice(&p.sample_rate.to_le_bytes()); + put_short(&mut o, p.gain_attr.as_bytes()); + put_short(&mut o, p.pan_attr.as_bytes()); + o.push(u8::try_from(p.max_tiles).expect("max_tiles fits a byte")); + o.push(u8::try_from(p.max_voices).expect("max_voices fits a byte")); + o + } + + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// The descriptor's digest (§3.2): SHA-256 over [`Compose::body`]. + pub fn digest(&self) -> [u8; 32] { + sha256(&[&self.body()]) + } + + /// Signed under `key`; `signer` is left as it is. + pub fn sign(mut self, key: &SigningKey) -> Compose { + let mut m = DESCRIPTOR_DOMAIN.to_vec(); + m.extend_from_slice(&self.body()); + self.sig = key.sign(&m).to_bytes(); + self + } + + /// Read a descriptor (§3.3): the record, its canvas, its field rules, its signature. + pub fn parse(b: &[u8]) -> Result { + read_head(b, &DESCRIPTOR_MAGIC)?; + let mut r = Rd { b, p: 5 }; + let room = r.arr()?; + let signer = r.arr()?; + let effective = r.u64()?; + let compositor = r.arr()?; + let width = r.u16()? as u32; + let height = r.u16()? as u32; + let fps = r.u8()?; + let background = r.arr()?; + let layer = r.u8()?; + let layout = r.u8()?; + let gap = r.u16()? as u32; + let aspect = r.u8()?; + let fill = r.u8()?; + let featured = r.short()?; + let order = r.short()?; + let omit = r.u8()?; + let pip_attr = r.short()?; + let corner = r.u8()?; + let pip_width = r.u16()? as u32; + let pip_height = r.u16()? as u32; + let offset = r.u16()? as u32; + let behaviour = r.u8()?; + let channels = r.u8()?; + let sample_rate = r.u32()?; + let gain = r.short()?; + let pan = r.short()?; + let max_tiles = r.u8()? as u32; + let max_voices = r.u8()? as u32; + let sig = r.arr()?; + if r.p != b.len() { + return Err(MALFORMED); + } + let keys = [featured, order, pip_attr, gain, pan]; + let keys_ok = keys.iter().all(|k| k.is_empty() || layout::key_ok(k)); + let s = |k: &[u8]| String::from_utf8_lossy(k).into_owned(); + let params = Params { + width, + height, + fps, + layout, + gap, + aspect, + fill, + featured_attr: s(featured), + order_attr: s(order), + omit_stopped: omit == 1, + pip_attr: s(pip_attr), + corner, + pip_width, + pip_height, + offset, + behaviour, + channels, + sample_rate, + gain_attr: s(gain), + pan_attr: s(pan), + max_tiles, + max_voices, + }; + params.check_canvas()?; + if !keys_ok || omit > 1 { + return Err(Reason::ComposeInvalid); + } + params.check_fields()?; + let c = Compose { room, signer, effective, compositor, background, layer, params, sig }; + verify_sig(&c.signer, DESCRIPTOR_DOMAIN, &b[..b.len() - 64], &c.sig)?; + debug_assert_eq!(c.body(), b[..b.len() - 64], "a descriptor that reads re-encodes to its bytes"); + Ok(c) + } +} + +// --------------------------------------------------------------------------------------- +// §4 The participant set +// --------------------------------------------------------------------------------------- + +/// A room as live-rooms §9 is given it. +#[derive(Clone, Copy, Debug)] +pub struct RoomInput<'a> { + pub descriptor: &'a [u8], + pub grants: &'a [Vec], + pub head: Option<&'a str>, + pub chains: &'a [Vec>], +} + +/// The room at one instant (§4.1): its state, and each present principal's join order. +#[derive(Clone, Debug)] +pub struct Instant { + pub state: RoomState, + pub joins: BTreeMap<[u8; 32], u32>, +} + +/// A room that verified (§9 step 1), with what composition reads of it. +pub struct VerifiedRoom<'a> { + pub input: RoomInput<'a>, + pub run: RoomRun, + pub d: Descriptor, + pub id: [u8; 32], +} + +impl<'a> VerifiedRoom<'a> { + /// live-rooms §9 over the room: its refusal; `lifecycle_incomplete` when a room key's + /// chain has a gap (§4.1). A gap in another principal's chain is not a refusal here. + pub fn verify(input: RoomInput<'a>) -> Result, (Place, Reason)> { + let run = verify_room(input.descriptor, input.grants, input.head, input.chains); + if let RoomOutcome::Refuse { at, reason } = run.outcome { + return Err((Place::Room(Some(at)), reason.into())); + } + let d = run.descriptor.clone().expect("a room that is not refused read its descriptor"); + if let RoomOutcome::Gap(gaps) = &run.outcome + && gaps.iter().any(|g| d.is_room_key(&g.principal)) + { + return Err((Place::Room(None), Reason::LifecycleIncomplete)); + } + let id = d.room_id(); + Ok(VerifiedRoom { input, run, d, id }) + } + + /// The grant a digest names. + pub fn grant(&self, digest: &[u8; 32]) -> Option<&Grant> { + self.run.grants.iter().find(|h| h.digest == *digest).map(|h| &h.grant) + } + + /// The room at `t` (§4.1): live-rooms §9 over each chain's records up to the first + /// whose `t` is above `t`, and the join order of each `join` among them. + pub fn at(&self, t: u64) -> Instant { + let chains: Vec>> = self + .input + .chains + .iter() + .map(|c| c.iter().take_while(|r| Event::parse(r).is_ok_and(|e| e.t <= t)).cloned().collect()) + .collect(); + let run = verify_room(self.input.descriptor, self.input.grants, self.input.head, &chains); + let mut joins = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let mut n = 0u32; + for &(c, i, _) in &run.order { + let Ok(ev) = Event::parse(&chains[c][i]) else { continue }; + if !seen.insert(ev.digest()) || ev.kind != kind::JOIN || !self.d.is_room_key(&ev.principal) { + continue; + } + if let Ok(Payload::Lifecycle { subject, .. }) = events::parse_payload(ev.kind, &ev.payload, u16::MAX) { + joins.insert(subject, n); + n += 1; + } + } + Instant { state: run.state, joins } + } + + /// The grant in effect of `p` at `at`, if `p` is present. + pub fn grant_in_effect(&self, at: &Instant, p: &[u8; 32]) -> Option<&Grant> { + at.state.present.get(p).and_then(|g| self.grant(g)) + } +} + +/// A participant's video or audio source (§4.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Source { + /// Empty for the participant's own track; otherwise the slot it is live in. + pub slot: String, + pub media: u8, + /// The index of its profile in the room's descriptor, and the layers it publishes. + pub profile: u8, + pub layers: u8, + /// The layer composed: the lower of `layers` − 1 and the descriptor's `layer`. + pub layer: u8, + pub layering: u8, + /// That layer's dimensions in the profile; 0 for audio. + pub width: u16, + pub height: u16, +} + +impl Source { + /// The tracks a compositor reads it from (§8.2), each as an anchor's `track` digest + /// (live-rooms §6.2): track 0 for `single` and `svc`, track `layer` for `simulcast`, + /// and tracks 0 to `layer` for `staged`. + pub fn tracks(&self, room_id: &[u8; 32], principal: &[u8; 32]) -> Vec<[u8; 32]> { + let owner = if self.slot.is_empty() { Owner::Principal(*principal) } else { Owner::Slot(self.slot.clone()) }; + let ns = render_namespace(&track_namespace(room_id, &owner, media_name(self.media).expect("a media kind"))); + let layers: Vec = match self.layering { + layering::SIMULCAST => vec![self.layer], + layering::STAGED => (0..=self.layer).collect(), + _ => vec![0], + }; + layers.into_iter().map(|l| anchor_track(&ns, &track_name(l))).collect() + } + + fn encode(s: Option<&Source>, o: &mut Vec) { + match s { + None => o.push(0), + Some(s) => { + o.push(s.media); + put_short(o, s.slot.as_bytes()); + o.extend_from_slice(&[s.profile, s.layers, s.layer]); + o.extend_from_slice(&s.width.to_le_bytes()); + o.extend_from_slice(&s.height.to_le_bytes()); + } + } + } +} + +/// One participant of the set (§4.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Participant { + pub principal: [u8; 32], + /// The digest of its grant in effect. + pub grant: [u8; 32], + pub join: u32, + /// Its grant's attributes (live-rooms §4.4). + pub attrs: Vec<(String, String)>, + pub video: Option, + pub audio: Option, +} + +fn source(d: &Descriptor, c: &Compose, held: &Held, slot: &str, m: u8) -> Source { + let p = &d.profiles[held.profile as usize]; + let layer = (held.layers - 1).min(c.layer); + let l = &p.layers[layer as usize]; + Source { slot: slot.into(), media: m, profile: held.profile, layers: held.layers, layer, layering: p.layering, width: l.width, height: l.height } +} + +/// §4.2: the participant set of `c` at `at`, in ascending order of key. +pub fn participants(c: &Compose, vr: &VerifiedRoom, at: &Instant) -> Vec { + let st = &at.state; + let d = &vr.d; + let live_in = |p: &[u8; 32], kinds: &[u8]| -> Option { + d.slots.iter().zip(&st.slots).find_map(|(s, ss)| match ss.live { + Some(h) if h.principal == *p && kinds.contains(&s.media) => Some(source(d, c, &h, &s.name, s.media)), + _ => None, + }) + }; + let own = |p: &[u8; 32], m: u8| st.own.get(&(*p, m)).map(|h| source(d, c, h, "", m)); + let mut out = Vec::new(); + for (p, g) in &st.present { + if *p == c.compositor { + continue; + } + let publishes = st.own.keys().any(|(q, _)| q == p) || st.slots.iter().any(|s| s.live.is_some_and(|h| h.principal == *p)); + if !publishes { + continue; + } + let video = own(p, media::VIDEO).or_else(|| own(p, media::SCREEN)).or_else(|| live_in(p, &[media::VIDEO, media::SCREEN])); + let audio = own(p, media::AUDIO).or_else(|| live_in(p, &[media::AUDIO])); + let attrs = vr.grant(g).map(|g| g.attrs.clone()).unwrap_or_default(); + let join = *at.joins.get(p).expect("a present principal joined"); + out.push(Participant { principal: *p, grant: *g, join, attrs, video, audio }); + } + out +} + +/// §4.5: the digest of the participant set at `t`. +pub fn set_digest(room_id: &[u8; 32], t: u64, ps: &[Participant]) -> [u8; 32] { + let mut o = Vec::new(); + o.extend_from_slice(SET_DOMAIN); + o.extend_from_slice(room_id); + o.extend_from_slice(&t.to_le_bytes()); + o.extend_from_slice(&(ps.len() as u32).to_le_bytes()); + for p in ps { + o.extend_from_slice(&p.principal); + o.extend_from_slice(&p.grant); + o.extend_from_slice(&p.join.to_le_bytes()); + Source::encode(p.video.as_ref(), &mut o); + Source::encode(p.audio.as_ref(), &mut o); + } + sha256(&[&o]) +} + +/// The composition at one instant: the set, its digest, and the layout and voices. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Frame { + pub t: u64, + pub participants: Vec, + pub set_digest: [u8; 32], + pub layout: Layout, +} + +impl Frame { + fn participant(&self, p: &[u8; 32]) -> &Participant { + self.participants.iter().find(|q| q.principal == *p).expect("a tile's participant is in the set") + } + + /// §8.2: the inputs a composed track of `m` reads, in order, as (principal, track): + /// for video, each tile that shows video, in drawing order; for audio, each voice. + pub fn inputs(&self, room_id: &[u8; 32], m: u8) -> Vec<([u8; 32], [u8; 32])> { + let mut out = Vec::new(); + let sources: Vec<(&[u8; 32], Option<&Source>)> = if m == media::AUDIO { + self.layout.voices.iter().map(|v| (&v.principal, self.participant(&v.principal).audio.as_ref())).collect() + } else { + self.layout.tiles.iter().filter(|t| t.video.is_some()).map(|t| (&t.principal, self.participant(&t.principal).video.as_ref())).collect() + }; + for (p, s) in sources { + for tr in s.expect("a composed source").tracks(room_id, p) { + out.push((*p, tr)); + } + } + out + } +} + +/// §4, §5 at `t` over the room as it is at `at`. +fn frame_at(c: &Compose, vr: &VerifiedRoom, t: u64, at: &Instant) -> Result { + if t < c.effective { + return Err(Reason::NotYetEffective); + } + let participants = participants(c, vr, at); + let entrants: Vec = participants + .iter() + .map(|p| Entrant { + principal: p.principal, + join: p.join, + video: p.video.as_ref().map(|s| (s.width as u32, s.height as u32)), + audio: p.audio.is_some(), + attrs: &p.attrs, + }) + .collect(); + let layout = layout::layout(&c.params, &entrants)?; + Ok(Frame { t, set_digest: set_digest(&vr.id, t, &participants), participants, layout }) +} + +/// The composition of `c` at `t` (§4, §5). +pub fn compose_at(c: &Compose, vr: &VerifiedRoom, t: u64) -> Result { + frame_at(c, vr, t, &vr.at(t)) +} + +/// §3.3, §3.5: a descriptor read, of this room, signed by its authority or by a principal +/// whose grant in effect at its `effective` has the compose power. +pub fn check_descriptor(b: &[u8], vr: &VerifiedRoom) -> Result { + let c = Compose::parse(b)?; + if c.room != vr.id { + return Err(WRONG_ROOM); + } + if c.signer != vr.d.authority { + let at = vr.at(c.effective); + if !vr.grant_in_effect(&at, &c.signer).is_some_and(|g| g.caps.powers & power::COMPOSE != 0) { + return Err(Reason::ComposeNotPermitted); + } + } + Ok(c) +} + +/// The outcome of composing a room at a list of instants. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ComposeOutcome { + Frames(Vec), + Refuse { at: Place, reason: Reason }, +} + +/// §9: the room, the descriptor, then the composition at each instant. +pub fn verify_composition(room: RoomInput, descriptor: &[u8], instants: &[u64]) -> (ComposeOutcome, Option<[u8; 32]>) { + let vr = match VerifiedRoom::verify(room) { + Ok(v) => v, + Err((at, reason)) => return (ComposeOutcome::Refuse { at, reason }, None), + }; + let c = match check_descriptor(descriptor, &vr) { + Ok(c) => c, + Err(reason) => return (ComposeOutcome::Refuse { at: Place::Descriptor, reason }, None), + }; + let mut frames = Vec::with_capacity(instants.len()); + for &t in instants { + match compose_at(&c, &vr, t) { + Ok(f) => frames.push(f), + Err(reason) => return (ComposeOutcome::Refuse { at: Place::Set(t), reason }, Some(c.digest())), + } + } + (ComposeOutcome::Frames(frames), Some(c.digest())) +} + +// --------------------------------------------------------------------------------------- +// §8 The composition receipt +// --------------------------------------------------------------------------------------- + +/// One MoQ object (§8.1): its track, as an anchor's `track` digest, its Location, and the +/// SHA-256 of its payload. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ObjectRef { + pub track: [u8; 32], + pub group: u64, + pub object: u64, + pub digest: [u8; 32], +} + +/// One input object, and the participant it is read from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputRef { + pub principal: [u8; 32], + pub object: ObjectRef, +} + +/// A composition receipt (§8.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Receipt { + pub room: [u8; 32], + pub compositor: [u8; 32], + /// The descriptor's digest. + pub descriptor: [u8; 32], + /// The room-clock instant of the output object. + pub t: u64, + /// The participant set's digest at `t` (§4.5). + pub state: [u8; 32], + /// 1 audio (a mix) or 2 video (a layout). + pub media: u8, + pub output: ObjectRef, + pub inputs: Vec, + pub sig: [u8; 64], +} + +fn put_object(o: &mut Vec, r: &ObjectRef) { + o.extend_from_slice(&r.track); + o.extend_from_slice(&r.group.to_le_bytes()); + o.extend_from_slice(&r.object.to_le_bytes()); + o.extend_from_slice(&r.digest); +} + +fn read_object(r: &mut Rd) -> Result { + Ok(ObjectRef { track: r.arr()?, group: r.u64()?, object: r.u64()?, digest: r.arr()? }) +} + +impl Receipt { + /// The receipt without `sig` (§8.1). + pub fn body(&self) -> Vec { + let mut o = Vec::new(); + o.extend_from_slice(&RECEIPT_MAGIC); + o.push(RECORD_VERSION); + o.extend_from_slice(&self.room); + o.extend_from_slice(&self.compositor); + o.extend_from_slice(&self.descriptor); + o.extend_from_slice(&self.t.to_le_bytes()); + o.extend_from_slice(&self.state); + o.push(self.media); + put_object(&mut o, &self.output); + assert!(self.inputs.len() <= u8::MAX as usize, "at most 255 inputs"); + o.push(self.inputs.len() as u8); + for i in &self.inputs { + o.extend_from_slice(&i.principal); + put_object(&mut o, &i.object); + } + o + } + + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// The receipt's digest (§8.1). + pub fn digest(&self) -> [u8; 32] { + sha256(&[&self.body()]) + } + + /// Signed under `key`; `compositor` is left as it is. + pub fn sign(mut self, key: &SigningKey) -> Receipt { + let mut m = RECEIPT_DOMAIN.to_vec(); + m.extend_from_slice(&self.body()); + self.sig = key.sign(&m).to_bytes(); + self + } + + /// Read a receipt (§8.3 step 1): the record, its rules, its signature. + pub fn parse(b: &[u8]) -> Result { + read_head(b, &RECEIPT_MAGIC)?; + let mut r = Rd { b, p: 5 }; + let room = r.arr()?; + let compositor = r.arr()?; + let descriptor = r.arr()?; + let t = r.u64()?; + let state = r.arr()?; + let media = r.u8()?; + let output = read_object(&mut r)?; + let n = r.u8()? as usize; + let mut inputs = Vec::with_capacity(n); + for _ in 0..n { + inputs.push(InputRef { principal: r.arr()?, object: read_object(&mut r)? }); + } + let sig = r.arr()?; + if r.p != b.len() { + return Err(MALFORMED); + } + let mut pairs = BTreeSet::new(); + if !matches!(media, media::AUDIO | media::VIDEO) || !inputs.iter().all(|i| pairs.insert((i.principal, i.object.track))) { + return Err(Reason::ReceiptInvalid); + } + let rc = Receipt { room, compositor, descriptor, t, state, media, output, inputs, sig }; + verify_sig(&rc.compositor, RECEIPT_DOMAIN, &b[..b.len() - 64], &rc.sig)?; + Ok(rc) + } +} + +/// The outcome of verifying a receipt (§8.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReceiptOutcome { + Accept { digest: [u8; 32], frame: Frame }, + Refuse { at: Place, reason: Reason }, +} + +/// §8.3: a receipt checked against the room and the descriptor it names. +pub fn verify_receipt(room: RoomInput, descriptor: &[u8], receipt: &[u8]) -> ReceiptOutcome { + let refuse = |at, reason| ReceiptOutcome::Refuse { at, reason }; + let vr = match VerifiedRoom::verify(room) { + Ok(v) => v, + Err((at, reason)) => return refuse(at, reason), + }; + let c = match check_descriptor(descriptor, &vr) { + Ok(c) => c, + Err(reason) => return refuse(Place::Descriptor, reason), + }; + let r = match Receipt::parse(receipt) { + Ok(r) => r, + Err(reason) => return refuse(Place::Receipt, reason), + }; + if r.room != vr.id { + return refuse(Place::Receipt, WRONG_ROOM); + } + if r.descriptor != c.digest() { + return refuse(Place::Receipt, Reason::DescriptorMismatch); + } + if r.t < c.effective { + return refuse(Place::Receipt, Reason::NotYetEffective); + } + let at = vr.at(r.t); + let composes = vr.grant_in_effect(&at, &r.compositor).is_some_and(|g| g.caps.powers & power::COMPOSE != 0); + if c.compositor == NO_COMPOSITOR || r.compositor != c.compositor || !composes { + return refuse(Place::Receipt, Reason::CompositorNotPermitted); + } + let ns = render_namespace(&track_namespace(&vr.id, &Owner::Principal(r.compositor), media_name(r.media).expect("a media kind"))); + let published = at.state.own.get(&(r.compositor, r.media)).is_some_and(|h| (0..h.layers).any(|l| anchor_track(&ns, &track_name(l)) == r.output.track)); + if !published { + return refuse(Place::Receipt, Reason::OutputNotPublished); + } + let frame = match frame_at(&c, &vr, r.t, &at) { + Ok(f) => f, + Err(reason) => return refuse(Place::Set(r.t), reason), + }; + if r.state != frame.set_digest { + return refuse(Place::Receipt, Reason::StateMismatch); + } + let stated: Vec<([u8; 32], [u8; 32])> = r.inputs.iter().map(|i| (i.principal, i.object.track)).collect(); + if stated != frame.inputs(&vr.id, r.media) { + return refuse(Place::Receipt, Reason::InputsMismatch); + } + ReceiptOutcome::Accept { digest: r.digest(), frame } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The corpus regenerates byte for byte, and this module gives every case of it the + /// outcome it states. + #[test] + fn corpus_round_trip() { + let doc = corpus::generate(); + let fails = corpus::verify(&doc); + assert!(fails.is_empty(), "{}", fails.join("\n")); + } + + /// A descriptor re-encodes to the bytes it was read from, and one bit of its body + /// changed fails its signature. + #[test] + fn descriptor_round_trip() { + let key = SigningKey::from_bytes(&[7; 32]); + let c = Compose { + room: [1; 32], + signer: key.verifying_key().to_bytes(), + effective: 0, + compositor: NO_COMPOSITOR, + background: [0, 0, 0], + layer: 2, + params: corpus::grid_params(), + sig: [0; 64], + } + .sign(&key); + let b = c.to_bytes(); + assert_eq!(Compose::parse(&b), Ok(c)); + let mut bad = b.clone(); + bad[40] ^= 1; + assert_eq!(Compose::parse(&bad), Err(events::Reason::BadSignature.into())); + assert_eq!(Compose::parse(&b[..b.len() - 1]), Err(TRUNCATED)); + } +} diff --git a/wai/wai-rs/src/live_compose/layout.rs b/wai/wai-rs/src/live_compose/layout.rs new file mode 100644 index 000000000..62437c189 --- /dev/null +++ b/wai/wai-rs/src/live_compose/layout.rs @@ -0,0 +1,748 @@ +//! The layout function and the mixer of live-compose §5 and §6. +//! +//! Both are pure integer functions of their arguments: no floating point, no clock, no +//! I/O, no hashing, and nothing beyond `core`, `Vec` and `String`, so that a later slice +//! can build this file for the browser unchanged. Every division here is of non-negative +//! integers and rounds down; the mixer's one shift of a signed sum is stated where it is +//! made. [`layout`] is §5 over the participants §4 derives from a room; [`mix`] is §6. + +use core::cmp::{max, min, Ordering}; + +/// The longest canvas edge, in pixels (§3.4). +pub const MAX_EDGE: u32 = 1920; +/// The shortest canvas edge, in pixels (§3.4). +pub const MIN_EDGE: u32 = 16; +/// The most pixels a canvas holds (§3.4): 1920 × 1080. +pub const MAX_PIXELS: u32 = 2_073_600; +/// The highest frame rate (§3.4). +pub const MAX_FPS: u8 = 60; +/// The most tiles, and the most voices, a descriptor allows (§3.3). +pub const MAX_COMPOSED: u32 = 64; +/// A gain of one (§6): gains are in units of 1/4096. +pub const UNITY_GAIN: u32 = 4096; +/// The highest gain (§6): four. +pub const MAX_GAIN: u32 = 16_384; +/// The pan of a source hard right (§6); hard left is its negation. +pub const MAX_PAN: i32 = 4096; +/// The sample rates a descriptor may state (§3.3), in Hz. +pub const SAMPLE_RATES: core::ops::RangeInclusive = 8_000..=192_000; + +/// The layout kinds (§3.1). +pub mod kind { + pub const GRID: u8 = 1; + pub const PIP: u8 = 2; +} + +/// The tile aspects (§5.2): `AUTO` lays tiles out as `VIDEO` does and lets each fill its +/// cell. +pub mod aspect { + pub const AUTO: u8 = 0; + pub const VIDEO: u8 = 1; + pub const SQUARE: u8 = 2; + pub const PORTRAIT: u8 = 3; +} + +/// The fill modes (§5.6); `DEFAULT` is cover for grid tiles and the PiP window, and +/// contain for the featured tile. +pub mod fill { + pub const DEFAULT: u8 = 0; + pub const FILL: u8 = 1; + pub const COVER: u8 = 2; + pub const CONTAIN: u8 = 3; +} + +/// The PiP window's corners (§5.5). +pub mod corner { + pub const TOP_LEFT: u8 = 1; + pub const TOP_RIGHT: u8 = 2; + pub const BOTTOM_LEFT: u8 = 3; + pub const BOTTOM_RIGHT: u8 = 4; +} + +/// What the PiP participant does when it is the only one composed (§5.5). +pub mod behaviour { + pub const STATIC: u8 = 1; + pub const DYNAMIC: u8 = 2; +} + +/// Where a tile is placed (§5.7). +pub mod place { + pub const GRID: u8 = 0; + pub const FEATURED: u8 = 1; + pub const PIP: u8 = 2; + /// Each place's name, as the corpus writes it. + pub fn name(p: u8) -> &'static str { + match p { + FEATURED => "featured", + PIP => "pip", + _ => "grid", + } + } +} + +/// Why the layout or the mix is refused (§10). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + CanvasOutOfRange, + ComposeInvalid, + AttrMalformed, + MixInputMalformed, +} + +/// A rectangle, in pixels: its left edge, its top edge, its width and its height. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct Rect { + pub x: u32, + pub y: u32, + pub w: u32, + pub h: u32, +} + +impl Rect { + pub fn new(x: u32, y: u32, w: u32, h: u32) -> Rect { + Rect { x, y, w, h } + } +} + +/// What the layout and the mix read of a descriptor (§3.1), its fields as integers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Params { + pub width: u32, + pub height: u32, + pub fps: u8, + pub layout: u8, + pub gap: u32, + pub aspect: u8, + pub fill: u8, + pub featured_attr: String, + pub order_attr: String, + pub omit_stopped: bool, + pub pip_attr: String, + pub corner: u8, + pub pip_width: u32, + pub pip_height: u32, + pub offset: u32, + pub behaviour: u8, + pub channels: u8, + pub sample_rate: u32, + pub gain_attr: String, + pub pan_attr: String, + pub max_tiles: u32, + pub max_voices: u32, +} + +/// An attribute key (live-rooms §4.4): 1 to 32 of `a`–`z`, `0`–`9` and `_`. +pub fn key_ok(k: &[u8]) -> bool { + (1..=32).contains(&k.len()) && k.iter().all(|&c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_') +} + +/// The least `k` with `k × k` ≥ `n`. +fn ceil_sqrt(n: u64) -> u64 { + let mut k = 0; + while k * k < n { + k += 1; + } + k +} + +/// The height of the featured tile's region (§5.4): three quarters of what the gap leaves, +/// rounded down. `gap` < `height`. +fn main_height(height: u64, gap: u64) -> u64 { + (height - gap) * 3 / 4 +} + +impl Params { + /// §3.4: the canvas within its limits. Otherwise `canvas_out_of_range`. + pub fn check_canvas(&self) -> Result<(), Refusal> { + let edge = |e: u32| (MIN_EDGE..=MAX_EDGE).contains(&e) && e % 2 == 0; + if !edge(self.width) || !edge(self.height) || self.width * self.height > MAX_PIXELS || !(1..=MAX_FPS).contains(&self.fps) { + return Err(Refusal::CanvasOutOfRange); + } + Ok(()) + } + + /// §3.3's field rules after the canvas's. Otherwise `compose_invalid`. + pub fn check_fields(&self) -> Result<(), Refusal> { + let bad = Err(Refusal::ComposeInvalid); + let (w, h, g) = (self.width as u64, self.height as u64, self.gap as u64); + if !matches!(self.layout, kind::GRID | kind::PIP) || self.aspect > aspect::PORTRAIT || self.fill > fill::CONTAIN { + return bad; + } + for k in [&self.featured_attr, &self.order_attr, &self.pip_attr, &self.gain_attr, &self.pan_attr] { + if !k.is_empty() && !key_ok(k.as_bytes()) { + return bad; + } + } + if self.layout == kind::GRID { + if !self.pip_attr.is_empty() || self.corner != 0 || self.pip_width != 0 || self.pip_height != 0 || self.offset != 0 || self.behaviour != 0 { + return bad; + } + } else { + let o = self.offset as u64; + if !(corner::TOP_LEFT..=corner::BOTTOM_RIGHT).contains(&self.corner) + || !(behaviour::STATIC..=behaviour::DYNAMIC).contains(&self.behaviour) + || o >= min(w, h) + || self.pip_width as u64 + o > w + || self.pip_height as u64 + o > h + { + return bad; + } + } + if !(1..=2).contains(&self.channels) || !SAMPLE_RATES.contains(&self.sample_rate) { + return bad; + } + if !(1..=MAX_COMPOSED).contains(&self.max_tiles) || !(1..=MAX_COMPOSED).contains(&self.max_voices) { + return bad; + } + // Every grid the descriptor can lay out has cells of at least one pixel (§3.3). + let k = ceil_sqrt(self.max_tiles as u64); + let need = k + (k - 1) * g; + if g >= h || w < need || h - g - main_height(h, g) < need { + return bad; + } + Ok(()) + } + + /// Both. + pub fn check(&self) -> Result<(), Refusal> { + self.check_canvas()?; + self.check_fields() + } +} + +// --------------------------------------------------------------------------------------- +// §4.3 Attributes +// --------------------------------------------------------------------------------------- + +/// The value of `key` among `attrs`; none when `key` is empty or not there. +pub fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> { + if key.is_empty() { + return None; + } + attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) +} + +/// `true` or `false`, exactly (§4.3); absent is `false`. +pub fn read_bool(v: Option<&str>) -> Result { + match v { + None | Some("false") => Ok(false), + Some("true") => Ok(true), + Some(_) => Err(Refusal::AttrMalformed), + } +} + +/// A decimal integer (§4.3): `0`, or an optional `-` and 1 to 10 digits not starting with +/// `0`. +pub fn read_int(v: &str) -> Option { + let b = v.as_bytes(); + let (neg, digits) = match b.first() { + Some(b'-') => (true, &b[1..]), + _ => (false, b), + }; + if digits.is_empty() || digits.len() > 10 || !digits.iter().all(u8::is_ascii_digit) { + return None; + } + if digits[0] == b'0' && (digits.len() > 1 || neg) { + return None; + } + let n = digits.iter().fold(0i64, |n, &d| n * 10 + (d - b'0') as i64); + Some(if neg { -n } else { n }) +} + +fn read_ranged(v: Option<&str>, lo: i64, hi: i64, absent: i64) -> Result { + match v { + None => Ok(absent), + Some(s) => read_int(s).filter(|n| (lo..=hi).contains(n)).ok_or(Refusal::AttrMalformed), + } +} + +/// One participant as the layout reads it (§4.2). +#[derive(Clone, Copy, Debug)] +pub struct Entrant<'a> { + pub principal: [u8; 32], + /// Its join order (§4.2). + pub join: u32, + /// Its video source's width and height, each at least 1; none when its video is + /// stopped. + pub video: Option<(u32, u32)>, + /// Whether it has an audio source. + pub audio: bool, + /// The attributes of its grant in effect (live-rooms §4.4). + pub attrs: &'a [(String, String)], +} + +/// The attributes a descriptor reads, read for one participant (§4.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Read { + pub featured: bool, + pub order: Option, + pub pip: bool, + pub gain: u32, + pub pan: i32, +} + +/// §4.3 over one participant's attributes: `attr_malformed` when one the descriptor names +/// does not read. +pub fn read_attrs(p: &Params, attrs: &[(String, String)]) -> Result { + let featured = read_bool(attr(attrs, &p.featured_attr))?; + let order = match attr(attrs, &p.order_attr) { + None => None, + Some(v) => Some(read_int(v).filter(|n| (i32::MIN as i64..=i32::MAX as i64).contains(n)).ok_or(Refusal::AttrMalformed)? as i32), + }; + let pip = read_bool(attr(attrs, &p.pip_attr))?; + let gain = read_ranged(attr(attrs, &p.gain_attr), 0, MAX_GAIN as i64, UNITY_GAIN as i64)? as u32; + let pan = read_ranged(attr(attrs, &p.pan_attr), -(MAX_PAN as i64), MAX_PAN as i64, 0)? as i32; + Ok(Read { featured, order, pip, gain, pan }) +} + +// --------------------------------------------------------------------------------------- +// §5 The layout +// --------------------------------------------------------------------------------------- + +/// A source's video on the canvas (§5.6): the rectangle it is drawn into, and the +/// rectangle of the source drawn there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Video { + pub dst: Rect, + pub src: Rect, +} + +/// One tile (§5.7). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tile { + pub principal: [u8; 32], + /// [`place`]: grid, featured or PiP. + pub place: u8, + /// 0 for a grid or featured tile, 1 for the PiP window, drawn over the others. + pub z: u8, + pub tile: Rect, + /// None when the participant's video is stopped: the tile shows the background. + pub video: Option