wai: live-compose, a room's composition as an integer function (L3) #296

Merged
dcharlot merged 10 commits from wai/live-compose into main 2026-10-17 16:04:02 -04:00
21 changed files with 24099 additions and 17 deletions

View file

@ -75,6 +75,9 @@ on:
# tests/capture_take.rs requires this extension's §12 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/capture-binding.md'
# tests/live_compose.rs requires this extension's §10 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-compose.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -121,6 +124,9 @@ on:
# tests/capture_take.rs requires this extension's §12 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/capture-binding.md'
# tests/live_compose.rs requires this extension's §10 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-compose.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -773,7 +779,8 @@ jobs:
# staged_verify.py (the staged-delivery corpus), and http_verify.py (the HTTP delivery
# corpus), and quantum-conformance/tools/referee.py (both quantum-sim capabilities), and
# live_events_verify.py (the live-events corpus), and live_rooms_verify.py (the
# live-rooms corpus), and capture_take_verify.py (the capture-binding corpus).
# live-rooms corpus), and capture_take_verify.py (the capture-binding corpus), and
# live_compose_verify.py (the live-compose corpus).
# Each exits non-zero on any failure or any difference, and the step fails with it.
# One platform: what they check is the reading of the text, not the architecture.
independent-verifiers:
@ -944,6 +951,26 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features capture_take --lib -- capture_take:: output_tree::)
(cd wai-rs && cargo test --no-default-features --features capture_take --test capture_take)
python3 -I live-conformance/capture_take_verify.py live-conformance/takes
# wai.live.compose (extensions/live-compose.md): the reference module's unit tests (the
# layout over every grid of 1 to 64 tiles stays on its canvas with no tile overlapping
# another, the fill modes, the attributes, the mixer) and tests/live_compose.rs (the
# committed corpus regenerates byte for byte from its fixed seeds, wai-rs gives every
# case its stated outcome, every reason of §10 refuses a case, and §10 lists exactly
# the reference's codes); then the corpus's second implementation,
# live_compose_verify.py, written from the extension's text and sharing no code with
# wai-rs, which verifies each room with live_rooms_verify.py, steps through its
# lifecycle replay to each instant itself, gives every layout, mix, descriptor,
# composition and receipt the same outcome rectangle for rectangle and sample for
# sample, and requires every mutated copy of the corpus to fail; standard library
# only, run isolated (-I).
- name: live compose · wai-rs and live_compose_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_compose --lib live_compose::)
(cd wai-rs && cargo test --no-default-features --features live_compose --test live_compose)
python3 -I live-conformance/live_compose_verify.py live-conformance/compose
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,

View file

@ -6,6 +6,45 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.live.compose`: a room's composition as an integer function
- **The extension** ([live-compose](extensions/live-compose.md)) registers the record
`wai.live.compose`. A `WCD1` composition descriptor, signed by the room authority or by a
principal whose grant has the compose power, fixes a canvas (each edge even, 16 to 1920
pixels, at most 1920 × 1080 pixels, 1 to 60 frames a second), an sRGB background, the
highest layer composed, a layout, the grant attributes it reads, and its limits on tiles
and voices.
- **The participant set** at a room-clock instant is the room's lifecycle replay up to it:
every present publisher but the compositor, its video source (own video, own screen, or
its live place in a slot) and audio source, each with the dimensions of the composed
layer, the attributes of its grant in effect, and its join order. A gap in a room key's
chain is `lifecycle_incomplete`; a gap elsewhere changes nothing. The set has a digest.
- **The layout** (§5) is integer arithmetic throughout, with every rounding stated: a grid
chosen by the largest tile of the reference aspect among every column count, its rows
centred; tile aspects auto, 16:9, 1:1 and 3:4; a featured participant above the others;
fill, cover and contain, by default cover for grid tiles and contain for the featured
one; stopped video shown or omitted; order by attribute, then join order, then key; and
picture in picture, the PiP participant's window over the grid in any corner, of a set or
derived size, static or dynamic (alone, it is laid out as though it had no PiP
attribute). The layout is computed again at every lifecycle event and grant exchange.
- **The mix** (§6): per-voice gain and pan as integers in units of 1/4096, over signed
16-bit PCM, summed exactly in 64 bits and saturated once, so it is byte-exact over the
decoded PCM; a decode and a lossy re-encode are not, and the receipt binds objects.
- **Where it runs** (§7): on each sink by default, with no server encode; a server
compositor must be the descriptor's compositor, hold the compose power and sign a `WCR1`
receipt for each object of a composed track, naming the descriptor, the set's digest at
`t`, the output object and the input objects of exactly the tracks the layout reads.
- **Live rooms.** §1, §3.3 and §4.4 point at the extension for the compose power and the
attributes `featured`, `order` and `pip` (and `gain` and `pan`, which it adds).
- **Reference and corpus.** `wai-rs` feature `live_compose` (`src/live_compose.rs`; the
layout and the mixer in `src/live_compose/layout.rs` use nothing beyond `core`, `Vec` and
`String`); `live-conformance/compose/`, written from fixed seeds by
`wai_live_compose_vectors`, and checked by `tests/live_compose.rs` and by
`live-conformance/live_compose_verify.py`, a second implementation that shares no code
with `wai-rs`, steps through the room's lifecycle replay itself and requires every
mutated copy of the corpus to fail. The registry lists the record and its magics (`WCD1`,
`WCR1`).
### `wai.capture.take`: a device's master recording bound to a live room
- **The extension** ([capture-binding](extensions/capture-binding.md)) registers the

View file

@ -40,7 +40,7 @@ Every registered string has one kind.
- **record** — a signed record a WAI tool emits and verifies (a receipt kind:
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
`wai.live.room` and `wai.capture.take`).
`wai.live.room`, `wai.capture.take` and `wai.live.compose`).
It names no payload format.
A derivation or record string is not a payload capability, so it is not
@ -62,8 +62,8 @@ bindings ([http-delivery](extensions/http-delivery.md),
[moq-streaming-format](extensions/moq-streaming-format.md)), the key discovery
they serve ([jwp-receipts](extensions/jwp-receipts.md) §7),
[packaging](extensions/packaging.md), [live-events](extensions/live-events.md),
[live-rooms](extensions/live-rooms.md) and [capture-binding](extensions/capture-binding.md)
put on the wire outside a
[live-rooms](extensions/live-rooms.md), [capture-binding](extensions/capture-binding.md)
and [live-compose](extensions/live-compose.md) put on the wire outside a
manifest — magic numbers, a media type, `codecs` prefixes, paths, playlist tags and
attributes, document kinds and catalog fields — each with its kind, the section that
defines it, and whether it is defined, provisional (test material only until
@ -192,6 +192,7 @@ and a role are fixed once registered.
| `wai.image.jpegai` | payload | registered | image | standalone | royalty-free | neural-entropy-exact | — | §5 Neural capabilities |
| `wai.image.jxl` | payload | registered | image | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set |
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.mesh.draco` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
@ -288,7 +289,9 @@ and a role are fixed once registered.
| `WAI1` | magic | defined | SPEC §2 |
| `WAI2` | magic | defined | SPEC §7.1 |
| `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 |
| `WCD1` | magic | defined | live-compose §3.1 |
| `WCN1` | magic | defined | capture-binding §8.1 |
| `WCR1` | magic | defined | live-compose §8.1 |
| `WEP1` | magic | defined | live-events §5 |
| `WEV1` | magic | defined | live-events §3.1 |
| `WHP1` | magic | defined | http-delivery §6.2 |

View file

@ -592,7 +592,8 @@ kind:
`wai.asset.provenance`, `wai.quantum.calibration`, `wai.quantum.job`, and the
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
`wai.live.room` and `wai.capture.take`), which names no payload format.
`wai.live.room`, `wai.capture.take` and `wai.live.compose`), which names no payload
format.
Only payload, companion and refinement capabilities have a media class, a
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
@ -894,6 +895,22 @@ at its index), `aligned` (capture began at the count-in's instant), `complete`,
`publishable` for each purpose. The take maps onto C2PA live video output:
[`extensions/capture-binding.md`](extensions/capture-binding.md).
And for the **composition** of a room — `wai.live.compose`: a signed composition
descriptor, by the room authority or a principal whose grant has the compose power, that
fixes a canvas (at most 1920 pixels an edge and 1920 × 1080 pixels, at 1 to 60 frames a
second), a background, and a layout: a grid, with a featured participant above the
others, a gap, a tile aspect, a fill mode (fill, cover or contain), an order by
attribute and stopped video shown or omitted; or the same grid under a picture-in-picture
window in a corner, of a set or derived size, static or dynamic. The participant set at
each instant follows from the room's lifecycle replay: who publishes, each one's source
and its dimensions, the attributes of its grant in effect and its join order. The layout
is a pure integer function of the descriptor and the set, laid out again at every
lifecycle event and grant exchange, and the mix a saturating integer function of decoded
PCM, so every sink composes the same rectangles locally; a server compositor under the
compose power signs a receipt naming the descriptor, the set and the input and output
objects, which anyone checks against the same function:
[`extensions/live-compose.md`](extensions/live-compose.md).
### Payloads of the WAI-native integer codecs
This revision pins a payload format to each WAI-native integer codec whose row named

View file

@ -0,0 +1,592 @@
# WAI Extension: Live Composition
> Status: Draft. Registers the record `wai.live.compose`: the deterministic composition
> of a live room's participants. A signed **composition descriptor** fixes a canvas, a
> background, a layout (a grid, with a featured tile, or a grid under a
> picture-in-picture window), the grant attributes it reads and its limits. The
> **participant set** at a room-clock instant is derived from the room's lifecycle
> replay ([live-rooms](live-rooms.md) §9): 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 is then a pure integer function of the descriptor and the set, and the mix
> a pure integer function of decoded PCM, so every sink composes the same rectangles
> locally, with no server encode, and a server compositor that publishes a composed track
> signs a **composition receipt** that anyone can check against the same function. No new
> transport: a composed track is a track of the room, and the receipt names the objects it
> was made from. The reference implementation is the `live_compose` module of `wai-rs`
> (feature `live_compose`); the corpus is
> [`live-conformance/compose/`](../live-conformance/compose/), and its second
> implementation is `live-conformance/live_compose_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: `draft-ietf-moq-transport-22` (MOQT), an Internet-Draft
> not past Working Group Last Call, for the Full Track Name (§2.4.1) and the Location
> (§8.2); RFC 6716 §6; RFC 8032; FIPS 180-4; and IEC 61966-2-1 (sRGB). A section number of
> that draft in this text is that revision's.
## 1. Scope
In scope: the composition descriptor, its digest and signature, its limits and who may
sign it (§3); the participant set at an instant, the attributes the descriptor reads, the
order, and the set's digest (§4); the layout function (§5); the audio mix (§6); where
composition runs (§7); the composition receipt and its verification (§8, §9).
Out of scope: a transport; how a renderer scales and filters a source into its rectangle,
and how it converts the background's colour into its output's (the layout fixes
rectangles, not pixels); decoding, resampling and encoding; and the browser compositor
that renders a layout in a page, which a later slice builds from this function.
Relationship to the core spec: `wai.live.compose` is a **record** ([SPEC](../SPEC.md) §5):
a signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
## 2. Terms
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **grant in
effect**, **room key**, **merged order**, **slot**, **publish profile**: as in
live-events §2 and live-rooms §2, §8, §9.2.
- **Room authority**: the key that signs the room's descriptor (live-rooms §2).
- **Compose power**: bit 2 of a capability set's `powers` (live-rooms §3.3).
- **Canvas**: the picture a composition makes, `width` × `height` pixels.
- **Source**: the track a participant is composed from (§4.2).
- **Compositor**: the principal that publishes a composed track (§7).
- **Rectangle**: (`x`, `y`, `w`, `h`): its left edge and top edge, in pixels from the top
left of the canvas (or of a source), and its width and height.
- A **short** field is a u8 length and that many bytes. Integers are little-endian. A
digest is SHA-256 (FIPS 180-4), 32 bytes.
## 3. The composition descriptor
### 3.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCD1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id (live-rooms §3.2) |
| `signer` | 32 | the Ed25519 key that signs the descriptor (§3.5) |
| `effective` | 8 | u64: the room-clock instant from which the descriptor applies |
| `compositor` | 32 | the principal that may publish the composed tracks (§7); 32 zero bytes for none |
| `width`, `height` | 2 each | u16: the canvas, in pixels (§3.4) |
| `fps` | 1 | the canvas's frame rate, in frames per second (§3.4) |
| `background` | 3 | the canvas's colour: red, green and blue, 8 bits each, in sRGB (IEC 61966-2-1) |
| `layer` | 1 | the highest layer of a source composed (§4.2) |
| `layout` | 1 | 1 grid, 2 picture in picture (§5) |
| `gap` | 2 | u16: the pixels between two grid tiles |
| `aspect` | 1 | the grid tiles' aspect: 0 auto, 1 video (16:9), 2 square (1:1), 3 portrait (3:4) (§5.3) |
| `fill` | 1 | 0 default, 1 fill, 2 cover, 3 contain (§5.6) |
| `featured_attr` | short | the attribute key that marks the featured participant; empty for none (§4.3) |
| `order_attr` | short | the attribute key that orders participants; empty for none |
| `omit_stopped` | 1 | 1: a participant whose video is stopped has no tile; 0: it has one (§5.2) |
| `pip_attr` | short | the attribute key that marks the PiP participant; empty for none |
| `corner` | 1 | the PiP window's corner: 1 top left, 2 top right, 3 bottom left, 4 bottom right |
| `pip_width`, `pip_height` | 2 each | u16: the PiP window's size; 0 derives it (§5.5) |
| `offset` | 2 | u16: the window's distance, in pixels, from both edges of its corner |
| `behaviour` | 1 | what the PiP participant does when it is the only one composed: 1 static, 2 dynamic (§5.5) |
| `channels` | 1 | the mix's channels: 1 mono, 2 stereo (§6) |
| `sample_rate` | 4 | u32: the rate, in Hz, every voice is decoded to before it is mixed |
| `gain_attr` | short | the attribute key of a participant's gain; empty for none (§6) |
| `pan_attr` | short | the attribute key of a participant's pan; empty for none |
| `max_tiles` | 1 | the most tiles (§5.2) |
| `max_voices` | 1 | the most voices of the mix (§6) |
| `sig` | 64 | §3.2 |
A grid's `pip_attr` is empty and its `corner`, `pip_width`, `pip_height`, `offset` and
`behaviour` are 0 (§3.3).
### 3.2 Digest and signature
The **digest** of a descriptor is SHA-256 over its record from `magic` through
`max_voices`: every byte but `sig`. The **signature** is Ed25519 (RFC 8032) by `signer`
over the 17 bytes `wai:live-compose` (ASCII) and 0x01, followed by the bytes the digest
covers. It is verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key
that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is
`bad_signature`.
### 3.3 Reading and field rules
A reader reads exactly the bytes it is given as one descriptor, in the order of §3.1, and
refuses at the first of these that holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WCD1`: `malformed`. No byte after
the magic: `truncated`. A version other than 1: `unknown_version`.
2. The bytes end before a field the layout requires: `truncated`.
3. Bytes after `sig`: `malformed`.
It then holds the canvas to §3.4, and refuses one beyond its limits as
`canvas_out_of_range`. It then refuses, as `compose_invalid`, a descriptor that breaks any
of these rules:
- `layout` is 1 or 2, `aspect` at most 3, `fill` at most 3, and `omit_stopped` 0 or 1.
- Each attribute key is empty or a slot name of live-rooms §2 (1 to 32 bytes of `a`–`z`,
`0`–`9` and `_`), which is what a grant attribute's key is (live-rooms §4.4).
- A grid has an empty `pip_attr`, and `corner`, `pip_width`, `pip_height`, `offset` and
`behaviour` 0. A picture in picture has a `corner` of 1 to 4 and a `behaviour` of 1 or 2,
an `offset` below both `width` and `height`, `pip_width` + `offset` at most `width`,
and `pip_height` + `offset` at most `height`.
- `channels` is 1 or 2, and `sample_rate` 8 000 to 192 000.
- `max_tiles` and `max_voices` are each 1 to 64.
- With *k* the least integer whose square is at least `max_tiles`: `gap` is below
`height`; `width` is at least *k* + (*k* − 1) × `gap`; and the band under a featured
tile (§5.4), `height` − `gap` − ⌊(`height` − `gap`) × 3 / 4⌋, is at least *k* +
(*k* − 1) × `gap`. Every grid the layout makes then has a candidate whose cells are at
least a pixel each way (§5.3).
Then the signature (§3.2): `bad_key` or `bad_signature`.
### 3.4 The canvas
`width` and `height` are each even, and each 16 to 1920; `width` × `height` is at most
2 073 600, the pixels of 1920 × 1080; and `fps` is 1 to 60. The edges are even so that a
picture subsampled 4:2:0 has whole chroma samples. A portrait 1080 × 1920 and a square
1440 × 1440 canvas are within these limits.
### 3.5 Who may sign
Against a room, a descriptor that reads (§3.3) is refused as `wrong_room` when its `room`
is not the room id; and as `compose_not_permitted` unless its `signer` is the room
authority, or a principal present at `effective` whose grant in effect then (§4.1) has the
compose power.
The power is checked at `effective`, when the descriptor takes effect: a descriptor does
not lapse when its signer leaves or later loses the power. A composition has one
descriptor; a room that changes its layout signs another, and each composed track and
each receipt names the one it follows (§8).
## 4. The participant set
### 4.1 The room at an instant
A composition reads a room that verifies (live-rooms §9): one refused is refused here
with the room's reason, at `room`. A room whose outcome is a gap is composed only when no
gap is in a room key's chain: the lifecycle is then whole, and a gap in another
principal's chain (a chat not delivered) changes no room state. A gap in a room key's
chain is `lifecycle_incomplete`, at `room`: the state at no instant after it is known.
The **room at** *t* is the room state (live-rooms §9.3) replayed from the lifecycle events
whose `t` is at most *t*, in the merged order: equally, the room each chain gives when it
is cut before its first record whose `t` is above *t*. Events at *t* itself are applied,
so a principal that joins at *t* is in the set at *t*.
The **join order** of a principal present at *t* is the number of the `join` that made it
present (its last), where the room's accepted `join` events up to *t* are numbered from 0
in the merged order, a duplicate (live-events §6.2 step 1) counted once.
### 4.2 Participants and their sources
The **participants** at *t* are the principals present at *t*, other than the
descriptor's `compositor`, that publish at *t*: each has an own track of any media kind
published, or is the live publisher of a slot (live-rooms §7.1). A standby is not
composed for its place in a slot, since its media is not delivered.
Each participant has:
- its **video source**: the first of its own `video` track; its own `screen` track; and
its live place in a slot of media kind `video` or `screen`, the first such slot in the
room descriptor's order. With none, its video is **stopped**.
- its **audio source**: its own `audio` track; or else its live place in the first slot of
media kind `audio`. With none, it has no voice.
- the attributes of its grant in effect at *t* (live-rooms §4.4).
A source's **profile** is the profile its track was published under (the `publish` or
`takeover` that made it, live-rooms §7.2), and its **layers** the number of layers
published. Its **composed layer** *L* is the lower of `layers` − 1 and the descriptor's
`layer`, and its width and height are those of layer *L* of the profile (live-rooms §8.1);
an audio source's are 0. A descriptor whose `layer` is below a publisher's top layer
composes from a layer the compositor's grant can subscribe to, and every sink lays the
room out in that layer's pixels.
### 4.3 Attributes
For each participant, each attribute key the descriptor names (not empty) is looked up
among the attributes of its grant in effect, and read:
| key | value | when absent |
|---|---|---|
| `featured_attr`, `pip_attr` | `true` or `false` | `false` |
| `order_attr` | a decimal integer from −2 147 483 648 to 2 147 483 647 | no order |
| `gain_attr` | a decimal integer from 0 to 16 384 | 4096 |
| `pan_attr` | a decimal integer from −4096 to 4096 | 0 |
A **decimal integer** is `0`, or an optional `-` followed by 1 to 10 of the digits `0`–`9`
of which the first is not `0`: no `+`, no space, no `-0`. Every attribute the descriptor
names is read for every participant, whether or not it has the source the attribute is
about; one whose value is not of its row is `attr_malformed`, and the composition at *t*
is refused, at `set`. The keys `featured`, `order` and `pip` that live-rooms §4.4 names
are these attributes under the keys a descriptor gives them; `gain` and `pan` are the
mix's.
### 4.4 Order
The participants are ordered: those with an order first, by order ascending, then those
with none; between two of one order (or none), by join order ascending; and between two
of one join order, by key, as unsigned bytes. This order is **S**.
### 4.5 The set's digest
The digest of the participant set at *t* is SHA-256 over the 21 bytes
`wai:live-compose-set` (ASCII) and 0x01, the room id, *t* (u64), the number of
participants (u32), and then, for each participant in ascending order of key:
| field | size | meaning |
|---|---|---|
| `principal` | 32 | its key |
| `grant` | 32 | the digest of its grant in effect |
| `join` | 4 | u32: its join order |
| `video`, `audio` | 1 or 9 + *s* each | its source: one byte 0 for none; otherwise `media` (u8, the media kind of live-rooms §6.1), `slot` (short: empty for an own track), `profile` (u8, the profile's index in the room descriptor), `layers` (u8), the composed layer *L* (u8), `width` and `height` (u16 each) |
The grant's digest fixes its attributes, so the set's digest fixes everything §5 and §6
read of the room at *t*.
## 5. The layout
### 5.1 Integers
Every quantity of this section is an integer, and none is negative. ⌊*a* / *b*⌋, for *b*
above 0, rounds down. **fit**(*bw*, *bh*, *a*, *b*), the largest rectangle of aspect
*a*:*b* within *bw* × *bh*, is (*bw*, max(1, ⌊*bw* × *b* / *a*⌋)) when *bw* × *b* ≤ *bh*
× *a*, and (max(1, ⌊*bh* × *a* / *b*⌋), *bh*) otherwise. A rectangle of *w* × *h*
**centred** in (*x*, *y*, *W*, *H*) is (*x* + ⌊(*W* − *w*) / 2⌋, *y* + ⌊(*H* − *h*) / 2⌋,
*w*, *h*). No product here exceeds 2^63.
### 5.2 Which participants have tiles
From S (§4.4):
1. **V** is S, less every participant whose video is stopped when `omit_stopped` is 1.
2. For a picture in picture, the **PiP participant** P is the first of V whose `pip_attr`
attribute is `true`; with none there is no PiP window. For a grid there is none.
3. **G** is V without P, in order. The **featured participant** F is the first of G whose
`featured_attr` attribute is `true`; it is moved to the front of G.
4. G is cut to its first `max_tiles` members, less one when there is a P. F, being first,
is kept; when G is then empty there is no F.
### 5.3 The grid
A grid of *n* ≥ 1 tiles over a region (*X*, *Y*, *W*, *H*) is chosen by its **reference
aspect** *a*:*b*: 16:9 for auto and video, 1:1 for square, 3:4 for portrait.
- **Candidates.** For each number of columns *c* from 1 to *n*, with *r* = ⌈*n* / *c*⌉
rows: *c* is a candidate when (*c* − 1) × `gap` is below *W*, (*r* − 1) × `gap` below
*H*, and the cells, *cw* = ⌊(*W* − (*c* − 1) × `gap`) / *c*⌋ wide and *ch* = ⌊(*H* −
(*r* − 1) × `gap`) / *r*⌋ high, are each at least 1. Its **area** is *tw* × *th*, where
(*tw*, *th*) = fit(*cw*, *ch*, *a*, *b*).
- **The choice.** The candidate of the largest area; of several, the one with the most
columns. §3.3's rule on `gap` makes *c* = ⌈√*n*⌉ a candidate for every *n* a
descriptor allows.
- **Placement.** The rows are centred: the first row's top is *y*0 = *Y* + ⌊(*H* − (*r* ×
*ch* + (*r* − 1) × `gap`)) / 2⌋. Tile *k*, from 0, is in row *i* = ⌊*k* / *c*⌋ and column
*j* = *k* mod *c*. Every row holds *c* tiles but the last, which holds *n* − (*r* − 1) ×
*c*; a row of *m* tiles is centred, beginning at *x*0 = *X* + ⌊(*W* − (*m* × *cw* + (*m* −
1) × `gap`)) / 2⌋. Cell *k* is (*x*0 + *j* × (*cw* + `gap`), *y*0 + *i* × (*ch* + `gap`),
*cw*, *ch*).
- **The tile.** Under auto, tile *k* is its cell; otherwise it is fit(*cw*, *ch*, *a*, *b*)
centred in its cell.
With no tile the canvas is its background.
### 5.4 The featured tile
With F and *n* ≥ 2 members of G, the **featured tile** is (0, 0, `width`, *mh*), with *mh*
= ⌊(`height` − `gap`) × 3 / 4⌋, and the other *n* − 1 members are a grid (§5.3) over the
band below it, (0, *mh* + `gap`, `width`, `height` − *mh* − `gap`). The featured tile is its
whole region: `aspect` is the grid tiles'. With F alone the featured tile is the whole
canvas. With no F, the members of G are a grid over the whole canvas.
### 5.5 Picture in picture
With P and a G that is not empty, G is laid out over the canvas as §5.3 and §5.4 lay it out,
and P's **window** is drawn over it. With P and an empty G:
- **static**: P's window, and the background;
- **dynamic**: P is laid out as though it had no `pip_attr` attribute: a grid of P alone,
or, when P is featured, the featured tile, the whole canvas.
The window is `pip_width` × `pip_height` when both are above 0. Otherwise, with *a*:*b* the
width and height of P's video source, or the canvas's when P's video is stopped, it is
fit(*bw*, *bh*, *a*, *b*), where *bw* is `pip_width` when that is above 0, `width` −
`offset` when only `pip_height` is, and the lower of ⌊`width` / 4⌋ and `width` − `offset`
when neither is; and *bh* is `pip_height` when that is above 0 and `height` − `offset`
otherwise. With *o* the `offset` and *pw* × *ph* the window, it is at (*o*, *o*) in the top
left corner, (`width` − *o* − *pw*, *o*) in the top right, (*o*, `height` − *o* − *ph*) in
the bottom left, and (`width` − *o* − *pw*, `height` − *o* − *ph*) in the bottom right.
### 5.6 Fill
A tile (*tx*, *ty*, *tw*, *th*) shows a source of *sw* × *sh* by its fill mode. The source
is **wider** when *sw* × *th* ≥ *sh* × *tw*.
- **Fill**: the whole source, (0, 0, *sw*, *sh*), stretched over the whole tile.
- **Cover**: the source cropped to the tile's aspect, over the whole tile. A wider source's
crop is *cw* = max(1, ⌊*sh* × *tw* / *th*⌋) wide, at (⌊(*sw* − *cw*) / 2⌋, 0, *cw*, *sh*);
any other's is *ch* = max(1, ⌊*sw* × *th* / *tw*⌋) high, at (0, ⌊(*sh* − *ch*) / 2⌋, *sw*,
*ch*).
- **Contain**: the whole source, letterboxed in the tile. A wider source is drawn into
(*tx*, *ty* + ⌊(*th* − *dh*) / 2⌋, *tw*, *dh*) with *dh* = max(1, ⌊*tw* × *sh* / *sw*⌋);
any other into (*tx* + ⌊(*tw* − *dw*) / 2⌋, *ty*, *dw*, *th*) with *dw* = max(1, ⌊*th* ×
*sw* / *sh*⌋).
- **Default**: cover for a grid tile and the PiP window, contain for the featured tile.
Each mode gives a **source rectangle** and a **destination rectangle**: the part of the
source drawn, and where on the canvas it is drawn. A tile of stopped video has neither, and
shows the background, as does the part of a tile outside its destination. How the source
rectangle is scaled into the destination is the renderer's; a sink that holds a source at a
layer below its composed one scales it to the composed layer's width and height first.
### 5.7 What the layout gives
The layout of a descriptor and a participant set gives:
- **the order**: S, every participant, by key;
- **the tiles**, in drawing order: the featured tile, then the grid's in G's order, then
the PiP window. Each has its participant, its **place** (`grid`, `featured` or `pip`),
its **z** (0, or 1 for the PiP window, drawn over the others), the tile, and, unless its
video is stopped, its source and destination rectangles;
- **the voices** (§6).
A renderer paints the background over the canvas, then the tiles in order. No two tiles
of z 0 overlap.
### 5.8 Re-layout
The participant set changes only at the `t` of a lifecycle event: a `join`, `leave`,
`publish`, `unpublish`, `grant_exchange`, `takeover` or `failover` (an exchange always
changes it, since the set names each participant's grant). At every such instant at or
after `effective` the layout is computed again, and it changes when the sources, the
attributes or the order do; between two such instants it does not change. The layout at *t*
is the function of this section over the descriptor and the set at *t*, and of nothing a
sink sees otherwise, so a sink that joins late lays the room out as one that was there
throughout, and a grant exchange that changes an attribute re-lays the room out at the
exchange's tick, with no participant leaving.
## 6. The audio mix
The **voices** are the members of S (before any is omitted for its video) that have an
audio source, in S's order, the first `max_voices` of them. Each has its **gain** *g*,
from 0 to 16 384 in units of 1/4096 (4096 is unity), and its **pan** *p*, from −4096
(left) to 4096 (right) (§4.3).
The mixer mixes one block of *N* frames. Each voice's input is its decoded audio for the
block: *N* frames of signed 16-bit samples, of 1 or 2 channels (interleaved, left first),
at the descriptor's `sample_rate`. Each output sample is a sum over the voices in signed
64-bit integers, one term per voice:
- **mono output** (`channels` 1): *x* × 4096 × *g* for a mono voice's sample *x*; (*x*L +
*x*R) × 2048 × *g* for a stereo voice's samples *x*L and *x*R.
- **stereo output** (`channels` 2), with *a*L = 4096 − max(*p*, 0) and *a*R = 4096 +
min(*p*, 0): to the left channel *x* × *g* × *a*L and to the right *x* × *g* × *a*R for
a mono voice; *x*L × *g* × *a*L and *x*R × *g* × *a*R for a stereo one.
The output sample is ⌊(*s* + 2^23) / 2^24⌋ of the sum *s*, the arithmetic shift right by
24 bits of *s* + 2^23, which rounds half up; then saturated once to −32 768 … 32 767. A
term is at most 2^41 in magnitude and a mix has at most 64 voices, so the sum is exact,
the order of its terms changes nothing, and saturation applies to the whole once. The pan
is a balance: at the centre both channels are at the voice's gain, and panning attenuates
one channel only. A single voice at unity gain and the centre is passed through
unchanged. A mixer that holds no decoded block of a voice for the output mixes it as
silence.
A mixer is refused as `mix_input_malformed` when an output has other than 1 or 2
channels, or an input has other than 1 or 2 channels, has other than *N* frames, or a
gain or pan out of range.
The mix is byte-exact over the decoded PCM: two mixers given the same blocks give the
same samples. It is not byte-exact over what precedes and follows it. A decoder of a lossy
codec need not reproduce another decoder's PCM bit for bit (RFC 6716 §6 holds a decoder to
a quality measure, not to equality), and a lossy encode of the mix is not
byte-reproducible: two encoders, or one encoder under two settings, give different bytes.
The receipt (§8) therefore binds the input objects and the output object, not samples.
## 7. Where composition runs
**On each sink, by default.** A sink composes the room locally from the tracks it
subscribes to, by §5 and §6, with no server decode or encode. It needs no power: the
descriptor states how the room is laid out, and laying it out is reading it.
**On a server, under the compose power.** A principal that publishes a composed track (its
own `video` track for a layout, its own `audio` track for a mix, live-rooms §6.1) **MUST**
be the descriptor's `compositor`, **MUST** hold the compose power in its grant in effect,
and **MUST** sign a receipt (§8) for each object of a composed track it publishes. It is
not a participant of its own composition (§4.2), so a composed track is never composed
again. A subscriber subscribes to a composed track as to any other; the descriptor and the
receipts let it check that the track is the room's composition.
## 8. The composition receipt
### 8.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCR1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `compositor` | 32 | the compositor's key, which signs the receipt |
| `descriptor` | 32 | the digest of the descriptor it follows (§3.2) |
| `t` | 8 | u64: the room-clock instant of the output object |
| `state` | 32 | the digest of the participant set at `t` (§4.5) |
| `media` | 1 | 1 audio (a mix), 2 video (a layout) |
| `output` | 80 | the output object |
| `inputs` | 1 + 112 × *n* | a u8 count *n*, then each input: `principal` (32), the participant it is read from, and an object (80) |
| `sig` | 64 | Ed25519 by `compositor` |
An **object** is 80 bytes: `track` (32), the digest live-events §3.1 gives an anchor's
`track`, over the track's namespace as live-rooms §6.2 renders it, one zero byte, and its
name; `group` and `object` (u64 each), its MOQT Location (MOQT §8.2); and `digest` (32),
the SHA-256 of the object's payload. An input whose `digest` is 32 zero bytes states that
the compositor held no object of that track for the output: it drew the tile with no
video, or mixed the voice as silence.
The **digest** of a receipt is SHA-256 over its bytes before `sig`; its **signature** is
Ed25519 by `compositor` over the 25 bytes `wai:live-compose-receipt` (ASCII) and 0x01,
followed by the bytes the digest covers, verified strictly (SPEC §7 *Signatures*). A
reader reads exactly the bytes it is given, as §3.3 steps 1 to 3 read a descriptor with
`WCR1` for `WCD1` (`truncated`, `malformed`, `unknown_version`); refuses as
`receipt_invalid` a receipt whose `media` is not 1 or 2, or that names two inputs of one
participant and one track; and then verifies the signature (`bad_key`, `bad_signature`).
### 8.2 The inputs
The inputs of an output at *t* are, in this order:
- **video** (`media` 2): for each tile of the layout at *t* that shows video, in drawing
order (§5.7), the tracks of its participant's video source;
- **audio** (`media` 1): for each voice, in order (§6), the tracks of its audio source.
The **tracks** of a source are named as live-rooms §6.1 names them: the namespace is the
room, the owner (the participant's key, or `_` and the slot's name for a slot) and the
media kind; the names follow the profile's `layering` (live-rooms §6.3) at the composed
layer *L*: track `0` for `single` and `svc` (an `svc` track carries every layer), track
*L* for `simulcast`, and tracks `0` to *L* for `staged`, since a refinement is applied to
the layers below it ([staged-delivery](staged-delivery.md)). The receipt names, for each,
the object it read.
### 8.3 Verifying a receipt
A receipt verifier is given a room (as live-rooms §9.1), a descriptor and a receipt. In
this order, the first that fails names the refusal and where:
1. **The room** (§4.1): its refusal, at `room`.
2. **The descriptor** (§3.3, §3.5): at `descriptor`.
3. **The receipt** reads (§8.1), at `receipt`; and its `room` is the room id, otherwise
`wrong_room`.
4. Its `descriptor` is the descriptor's digest: otherwise `descriptor_mismatch`.
5. Its `t` is at or after the descriptor's `effective`: otherwise `not_yet_effective`.
6. The descriptor names a compositor, the receipt's `compositor` is it, and that principal
is present at `t` with a grant in effect that has the compose power: otherwise
`compositor_not_permitted`.
7. Its `output`'s `track` is a track of the compositor's own track of the receipt's media
kind, published at `t`, at a layer from 0 to its `layers` − 1: otherwise
`output_not_published`.
8. **The set and the layout** at `t` (§4, §5): their refusal, at `set`.
9. Its `state` is the set's digest: otherwise `state_mismatch`.
10. Its inputs, each its `principal` and its object's `track`, are §8.2's, in order:
otherwise `inputs_mismatch`.
A receipt that passes is **accepted**: its output object was composed under this
descriptor, from this set, out of objects of exactly the tracks the layout reads. Anyone
holding the room's events and the descriptor recomputes the layout and checks that the
inputs were the right ones; a party holding the objects checks their digests. The receipt
does not fix the output's pixels or samples (§6).
## 9. Verification
A **composition verifier** is given a room, a descriptor and a list of instants. It runs
these steps and stops at the first refusal, which it reports with the reason and where:
1. **The room** (§4.1): at `room`, with the room's own place (live-rooms §9.1) when the
room is refused.
2. **The descriptor** (§3.3, §3.5): at `descriptor`.
3. **Each instant**, in the list's order: the instant is at or after `effective`
(otherwise `not_yet_effective`), and the participants' attributes read (§4.3,
otherwise `attr_malformed`): at `set`, with the instant.
Its outcome is **refuse**, with the reason and the place; or the **composition**: the
descriptor's digest and, for each instant, the participants with their sources and
tracks, the set's digest, the order, the tiles and the voices. A receipt verifier (§8.3)
reports its refusals at the same places, and at `receipt`.
## 10. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `canvas_out_of_range` | §3.3, §3.4 |
| `compose_invalid` | §3.3 |
| `compose_not_permitted` | §3.5 |
| `not_yet_effective` | §8.3 step 5, §9 step 3 |
| `lifecycle_incomplete` | §4.1 |
| `attr_malformed` | §4.3 |
| `mix_input_malformed` | §6 |
| `receipt_invalid` | §8.1 |
| `descriptor_mismatch` | §8.3 step 4 |
| `compositor_not_permitted` | §7, §8.3 step 6 |
| `output_not_published` | §8.3 step 7 |
| `state_mismatch` | §8.3 step 9 |
| `inputs_mismatch` | §8.3 step 10 |
A verifier of this extension also raises `truncated`, `malformed` and `unknown_version`
for a descriptor or a receipt that does not read; `bad_key` and `bad_signature` for their
signatures; `wrong_room` for one of another room (§3.5, §8.3); and every code a room
verifier raises (live-rooms §11 and the live-events codes it lists), at `room`.
## 11. Conformance
[`live-conformance/compose/`](../live-conformance/compose/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_live_compose_vectors gen`. It has:
- **layouts**: the layout function alone (§5, §6's voices), each case its parameters and
its participants with their join orders, video sizes, audio and attributes, and the
order, tiles and voices it gives, or its refusal. Grids of 0 to 12 tiles on a 16:9
canvas, and on portrait, square and auto ones; every fill mode over sources of four
shapes and of extreme aspects; a featured participant present, absent, kept past the
tile limit, and first in order of two; ties in order broken by join order and by key;
stopped video shown and omitted; picture in picture in every corner under both
behaviours, alone static and alone dynamic, with derived sizes and stopped video; and
attributes that do not read.
- **mixes**: blocks of PCM and the mix of them (§6): passing through, both widths into
both, saturation high and low and only at the end, pans and gains at their limits,
rounding at ties, silence, and inputs refused.
- **descriptors**: records read (§3), with their digest and fields, or refused with each
reason a descriptor alone can have.
- **rooms**: a studio room under the live-rooms descriptor, grants and chains that its
vectors name, and four variants of it: a grant whose order does not read, a lifecycle
record not delivered, a viewer's chat not delivered, and a viewer's record refused.
- **compositions**: a room, a descriptor and instants (§9), with the composition or the
refusal. The grid over the whole session re-lays the room out at each lifecycle
instant, among them a grant exchange that reorders it; the picture in picture composes
a lower layer.
- **receipts**: a room, a descriptor and a receipt (§8.3), accepted with its digest and
the set's, or refused with each reason a receipt can have.
A conforming verifier gives every case the outcome, the reason, the place and the data the
corpus states. `wai_live_compose_vectors verify` and the test `tests/live_compose.rs`
check that the file regenerates byte for byte, that `wai-rs` gives every case its
outcome, that every reason of §10 refuses a case, and that §10 lists exactly the
reference's codes. `live-conformance/live_compose_verify.py` is the second implementation,
written from this text: it shares no code with `wai-rs`, verifies signatures with WAI's
strict rule written from RFC 8032 (`claim-conformance/_ed25519.py`), verifies each room
with the live-rooms corpus's own second implementation (`live_rooms_verify.py`) and
replays its lifecycle to each instant itself, and gives every case the same outcome,
rectangle for rectangle and sample for sample; it also mutates copies of the corpus and
requires every copy to fail.
## 12. Security and privacy considerations
- The layout is computed from what the room authority signed: the descriptor, or a
principal's under the power the authority granted, and the attributes of grants the
authority signed. A participant cannot place itself; a relay that drops or reorders
lifecycle events is seen as a gap in a room key's chain (§4.1), not as another layout.
- A server compositor's receipt fixes which objects it composed and under which set. A
compositor that left a participant out, read a layer other than the composed one, or
composed a stale set is refused by §8.3. The receipt does not fix the output's pixels
or samples, which a lossy encode does not reproduce (§6); a party that needs them
composes locally from the inputs.
- Composition on each sink needs no server to decode the room's media, so a room whose
tracks are end-to-end encrypted ([confidentiality](confidentiality.md)) can still be
laid out: the layout reads the room's events and the grants, never media.
- The participant set names every publisher, its grant and its join order. Composing a
room reveals nothing a member of it does not already hold, but a set digest published
outside the room links the participants it names; a room that needs participation
unlinkable across rooms gives principals a key per room (live-events §11).
- Attributes are read as the descriptor's keys name them and nothing else is read of a
grant's attributes, so an attribute a descriptor does not name changes nothing.

View file

@ -32,7 +32,7 @@ slots, and the `detail` of every lifecycle kind of live-events §4.13 (§7); the
profiles (§8); room verification (§9); and the bindings onto token formats (§10).
Out of scope: a transport; how a principal obtains a grant; composition, which reads the
grant's attributes (a later extension); the review workflow, of which this extension
grant's attributes ([live-compose](live-compose.md)); the review workflow, of which this extension
fixes only the policy's fields and their meaning (§3.6); and what a recording holds,
which the recording policy constrains (§3.7) and later extensions define: a device's
master recording is [capture-binding](capture-binding.md)'s take.
@ -108,7 +108,8 @@ A **role** maps a name to a **capability set**:
- `events`: the event kinds it may send, a kinds bitmap within the ceiling's `kinds`.
- `powers`: bit 0 **moderate** (its `decision` events, live-events §4.11, are a
moderator's), bit 1 **direct** (it may send `control` commands other than recording),
bit 2 **compose** (it may author the room's composition, a later extension), bit 3
bit 2 **compose** (it may sign the room's composition descriptor and publish a composed
track, [live-compose](live-compose.md) §3.5, §7), bit 3
**record** (it may send `record_start` and `record_stop`).
A role whose `events` permit `decision` has the moderate power; one whose `events` permit
@ -260,9 +261,11 @@ value (a long field holding UTF-8, RFC 3629). Keys are in strictly ascending byt
and the entries take at most 1024 bytes, not counting the field's own length. An entry
that runs past the field's end breaks the rule.
Attributes are for composition: a later extension lays a room out from them. It reads
`featured` (`true` or `false`), `order` (a decimal integer) and `pip` (`true` or
`false`); this revision reads their syntax alone. Capture binding
Attributes are for composition: [live-compose](live-compose.md) lays a room out from
them. It reads `featured` (`true` or `false`), `order` (a decimal integer) and `pip`
(`true` or `false`), and `gain` and `pan` (decimal integers) for the mix, under the keys a
composition descriptor names, and gives each its syntax and its meaning (live-compose
§4.3); this revision checks only the form of the entries. Capture binding
([capture-binding](capture-binding.md) §3) reads two more: `subject`, the principal a
device captures for, as 64 lowercase hexadecimal digits of its key; and `minor` (`true`
or `false`), whether the authority marks that principal a minor. Attributes are signed with the grant,

View file

@ -8,11 +8,13 @@ and the independent verifier of each, beside it.
| [`events/`](events/) | [live-events](../extensions/live-events.md) (`wai.live.events`) | `live_events_verify.py` |
| [`rooms/`](rooms/) | [live-rooms](../extensions/live-rooms.md) (`wai.live.room`) | `live_rooms_verify.py` |
| [`takes/`](takes/) | [capture-binding](../extensions/capture-binding.md) (`wai.capture.take`) | `capture_take_verify.py` |
| [`compose/`](compose/) | [live-compose](../extensions/live-compose.md) (`wai.live.compose`) | `live_compose_verify.py` |
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
`wai_live_room_vectors gen` writes `rooms/` and `wai_capture_take_vectors gen` writes
`takes/`, each from records authored in code, from fixed seeds (`events/README.md`,
`rooms/README.md`, `takes/README.md`). No recorded data.
`wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes
`takes/` and `wai_live_compose_vectors gen` writes `compose/`, each from records authored in
code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`,
`compose/README.md`). No recorded data.
Each verifier is written from its extension's text, shares no code with `wai-rs`, and is
run by `.github/workflows/wai-conformance.yml` beside the reference's own check:
@ -21,4 +23,5 @@ run by `.github/workflows/wai-conformance.yml` beside the reference's own check:
python3 live_events_verify.py events
python3 live_rooms_verify.py rooms
python3 capture_take_verify.py takes
python3 live_compose_verify.py compose
```

View file

@ -0,0 +1,111 @@
# live-conformance/compose — live composition
The corpus of [`extensions/live-compose.md`](../../extensions/live-compose.md) §11: the
layout function and the mixer on their own, `WCD1` composition descriptors, and rooms (a
signed `WRD1` descriptor, its `WRG1` grants and its principals' `WEV1` chains) composed at
a list of instants, with `WCR1` composition receipts checked against them.
**Composition equivalence.** A conforming implementation reaches, for every case, the
outcome the case states, rectangle for rectangle and sample for sample:
- a **layout** case's `params` (the descriptor's fields, §3.1) and `entrants` (each a
`principal`, its `join` order, its `video` as `[width, height]` or `null`, whether it has
`audio`, and its `attrs` as `[key, value]` pairs) give the `order`, the `tiles` and the
`voices` of §5 and §6, or the refusal `reason`. A tile is its `principal`, its `place`
(`grid`, `featured` or `pip`), its `z`, its `tile` rectangle and its `dst` and `src`
rectangles (`null` when its video is stopped), each `[x, y, w, h]`; a voice its
`principal`, `gain` and `pan`;
- a **mix** case's `channels`, `frames` and `inputs` (each its `channels`, `gain`, `pan` and
`pcm`, signed 16-bit little-endian samples in hexadecimal) give the mixed `pcm` (§6), or
the refusal;
- a **descriptor** case's `bytes` read (§3.3) to its `digest` and `fields`, or are refused;
- a **composition** case's `room`, `descriptor` and `instants` give the descriptor's
`digest` and a frame per instant (§9): its `participants` (each its `principal`, its
`grant`, its `join` order, its `attrs`, and its `video` and `audio` sources with their
slot, media kind, profile, layers, composed layer, layering, dimensions and `tracks`),
its `set_digest` (§4.5), and its `order`, `tiles` and `voices`; or a refusal and where,
`at`: `["room"]`, `["room", …]` with the room verifier's own place (live-rooms §9.1),
`["descriptor"]` or `["set", t]`;
- a **receipt** case's `room`, `descriptor` and `receipt` are accepted (§8.3) with the
receipt's `digest` and the `set_digest` it names, or refused, `at` as above or
`["receipt"]`.
A verifier that reaches another outcome, reason, place, rectangle, sample or digest does
not conform.
**Provenance.** Generated here: `wai_live_compose_vectors gen` writes every case, room,
descriptor, record and receipt from scenes authored in code, from fixed seeds. Each key is
SHA-256 of a fixed label and the principal's index, each pure layout case's participant a
SHA-256 of a fixed label and an index (a name, not a key), each block of PCM SHA-256 output
under a fixed label (it stands for decoded audio and is no recording), each object digest
of a receipt SHA-256 of a fixed label (it stands for an object and hashes none), every time
is a constant (the room's `t0` is 2026-10-17T00:00:00Z), and Ed25519 signing is
deterministic (RFC 8032), so the file is the same bytes on every machine. The generator
asserts that each case reaches the outcome it was written to have before it writes it. No
recorded data.
## Layout
One file, `vectors.json`:
- `principals`: the room's keys, by name (informative).
- `layouts`, `mixes`, `descriptors`: the cases above, each with `name` and `about`.
- `rooms`: by name, each a `descriptor` and `grants` (hexadecimal), `key_log_head` (`null`:
the room names no origin) and `chains` (lists of records in hexadecimal).
- `compositions`, `receipts`: the cases above, each naming its room.
The **studio** room has a director (its role has the direct and compose powers), a
performer (camera and music, featured, order 1, gain 5120), a guest (speech only at first,
order 3, then exchanged at 5 s for a grant of order 0, gain 2048 and pan −2048, then video
at 7 s), a screen-sharing device, an encoder live in the slot `main` under a staged
profile of the room's own, a mixer (the compose power; it publishes the composed tracks),
and a viewer. The director's video stops at 6 s and the performer leaves at 8 s. Its
variants: `studio_bad_order` (the guest's order is `third`), `studio_lifecycle_gap` (the
relay's record at 6 s not delivered), `studio_viewer_gap` (the viewer's first chat not
delivered) and `studio_refused` (the viewer sends a `control` it may not).
Cases:
- layouts: `grid_0` to `grid_12`; portrait, square and auto canvases; `fill_fill`,
`fill_cover`, `fill_contain`, `fill_default` and extreme aspects; the featured
participant alone, of four, on a portrait canvas, kept past the tile limit, first of two,
absent, and unread; order by attribute and ties broken by key; stopped video shown and
omitted; the tile limit and the voice limit; picture in picture in each corner, static
and dynamic, alone, with derived sizes, with stopped video, first of two, and over a
featured tile; and attributes that do not read;
- mixes: passing through, both widths into both, saturation, pans, gains, ties, silence and
five refusals;
- descriptors: the room's three and canvases at their limits, and each refusal a descriptor
alone can have;
- compositions: `grid_session` (every second from 1 s to 9 s), `pip_session`,
`participant_gap`, `signer_with_power`, and a refusal for a signer without the power, one
not in the room, another room, a bad signature, an instant before `effective`, an
attribute that does not read, a lifecycle gap and a refused room;
- receipts: `video`, `audio` and `pip_video` accepted, and one refused for each reason a
receipt can have.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_compose --bin wai_live_compose_vectors -- verify ../live-conformance/compose
cargo run --no-default-features --features live_compose --bin wai_live_compose_vectors -- gen ../live-conformance/compose # regenerate
cargo test --no-default-features --features live_compose --test live_compose
cd ../live-conformance
python3 live_compose_verify.py compose
```
`wai_live_compose_vectors verify` and `tests/live_compose.rs` require the file to regenerate
byte for byte and `wai-rs` to give every case its outcome; the test also requires every
reason of §10 to refuse a case, the cases above to be there, the grid session to change its
order at the guest's exchange, and §10 to list exactly the reasons the reference reports.
`live_compose_verify.py` is the second implementation, written from the extension's text
and sharing no code with `wai-rs`: it verifies each room with `live_rooms_verify.py` and
steps through its lifecycle replay to take the room at each instant, gives every case the
same outcome, requires the same coverage of reasons, and then mutates copies of the corpus,
one defect each (a tile moved, tiles swapped, the PiP window's z, a source or destination
rectangle, the order, an entrant's attribute, a parameter, a stated reason, a voice, a mixed
or input sample, a pan, a descriptor's byte or digest, a frame's tile, set digest, join
order or tracks, an instant, a lifecycle record not delivered, a grant's byte, a receipt's
byte or digest, and a stated outcome), each of which must fail. Pass = an exit status of 0
from each.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,886 @@
#!/usr/bin/env python3
"""Independent verifier of the live-composition corpus (extensions/live-compose.md).
Written from the extension's text. It shares no code with wai-rs, which generates the
corpus, and never runs it. It checks Ed25519 signatures with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from
RFC 8032 with the standard library. Each room is verified by the live-rooms corpus's own
second implementation, live_rooms_verify.py (live-rooms §9), whose lifecycle replay this
verifier steps through itself, record by record, to take the room at each instant (§4.1).
Everything else is here, from the text:
- the descriptor (§3): read, its canvas, its field rules, its signature and who may sign;
- the participant set (§4): sources, attributes, order, and the set's digest;
- the layout function (§5): the grid and its candidates, the featured tile, picture in
picture, the fill modes; and the mixer (§6), over the corpus's PCM;
- the receipt (§8): read, its inputs, and its verification.
For every case it requires the outcome, reason and place, and the order, tiles, voices,
samples, digests, participants and sources the corpus states; of the corpus, that every
reason of §10 refuses a case. Then it mutates copies of the corpus, one defect each, and
requires every copy to fail.
python3 live_compose_verify.py <live-conformance/compose>
Exit status 0 only if every case verifies and every mutated copy fails. Python 3.8 or
later; standard library only.
"""
import copy
import hashlib
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import live_events_verify as LEV # noqa: E402
import live_rooms_verify as LRV # noqa: E402
import _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
DESCRIPTOR_DOMAIN = b"wai:live-compose\x01"
RECEIPT_DOMAIN = b"wai:live-compose-receipt\x01"
SET_DOMAIN = b"wai:live-compose-set\x01"
ZERO = bytes(32)
AUDIO, VIDEO, SCREEN = 1, 2, 3
MEDIA_NAME = {1: "audio", 2: "video", 3: "screen"}
COMPOSE_POWER = 4
JOIN = 0x10
GRID, PIP = 1, 2
AUTO, VIDEO_ASPECT, SQUARE, PORTRAIT = 0, 1, 2, 3
DEFAULT, FILL, COVER, CONTAIN = 0, 1, 2, 3
STATIC, DYNAMIC = 1, 2
SINGLE, SIMULCAST, SVC, STAGED = 0, 1, 2, 3
# §10, in its order.
REASONS = [
"canvas_out_of_range", "compose_invalid", "compose_not_permitted", "not_yet_effective",
"lifecycle_incomplete", "attr_malformed", "mix_input_malformed", "receipt_invalid",
"descriptor_mismatch", "compositor_not_permitted", "output_not_published", "state_mismatch",
"inputs_mismatch",
]
PARAM_KEYS = ["width", "height", "fps", "layout", "gap", "aspect", "fill", "featured_attr", "order_attr",
"omit_stopped", "pip_attr", "corner", "pip_width", "pip_height", "offset", "behaviour",
"channels", "sample_rate", "gain_attr", "pan_attr", "max_tiles", "max_voices"]
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
class Reader:
"""Fields in order; running out is `truncated`."""
def __init__(self, b, p=0):
self.b, self.p = b, p
def take(self, n):
if self.p + n > len(self.b):
raise Refuse("truncated")
s = self.b[self.p:self.p + n]
self.p += n
return s
def u8(self):
return self.take(1)[0]
def u16(self):
return struct.unpack("<H", self.take(2))[0]
def u32(self):
return struct.unpack("<I", self.take(4))[0]
def u64(self):
return struct.unpack("<Q", self.take(8))[0]
def short(self):
return self.take(self.u8())
def read_head(b, magic):
"""§3.3 step 1."""
if len(b) < 4:
raise Refuse("truncated")
if b[:4] != magic:
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
raise Refuse("unknown_version")
def is_key(k):
"""live-rooms §2: a slot name, 1 to 32 of a-z, 0-9 and _."""
return 1 <= len(k) <= 32 and all(c in b"abcdefghijklmnopqrstuvwxyz0123456789_" for c in k)
# ---------------------------------------------------------------- §3 the descriptor
def canvas_ok(p):
"""§3.4."""
def edge(e):
return 16 <= e <= 1920 and e % 2 == 0
return edge(p["width"]) and edge(p["height"]) and p["width"] * p["height"] <= 2073600 and 1 <= p["fps"] <= 60
def least_root(n):
k = 0
while k * k < n:
k += 1
return k
def fields_ok(p):
"""§3.3's rules after the canvas's."""
w, h, g = p["width"], p["height"], p["gap"]
if p["layout"] not in (GRID, PIP) or p["aspect"] > 3 or p["fill"] > 3:
return False
for k in ("featured_attr", "order_attr", "pip_attr", "gain_attr", "pan_attr"):
if p[k] != "" and not is_key(p[k].encode()):
return False
if p["layout"] == GRID:
if p["pip_attr"] != "" or p["corner"] or p["pip_width"] or p["pip_height"] or p["offset"] or p["behaviour"]:
return False
else:
o = p["offset"]
if not (1 <= p["corner"] <= 4 and p["behaviour"] in (STATIC, DYNAMIC) and o < w and o < h
and p["pip_width"] + o <= w and p["pip_height"] + o <= h):
return False
if p["channels"] not in (1, 2) or not 8000 <= p["sample_rate"] <= 192000:
return False
if not (1 <= p["max_tiles"] <= 64 and 1 <= p["max_voices"] <= 64):
return False
k = least_root(p["max_tiles"])
need = k + (k - 1) * g
return g < h and w >= need and h - g - (h - g) * 3 // 4 >= need
def check_params(p):
if not canvas_ok(p):
raise Refuse("canvas_out_of_range")
if not fields_ok(p):
raise Refuse("compose_invalid")
def read_descriptor(b):
"""§3.3: the descriptor, or Refuse."""
read_head(b, b"WCD1")
r = Reader(b, 5)
c = {"room": r.take(32), "signer": r.take(32), "effective": r.u64(), "compositor": r.take(32)}
p = {"width": r.u16(), "height": r.u16(), "fps": r.u8()}
c["background"], c["layer"] = r.take(3), r.u8()
p["layout"], p["gap"], p["aspect"], p["fill"] = r.u8(), r.u16(), r.u8(), r.u8()
raw = {"featured_attr": r.short(), "order_attr": r.short()}
omit = r.u8()
raw["pip_attr"] = r.short()
p["corner"], p["pip_width"], p["pip_height"], p["offset"], p["behaviour"] = r.u8(), r.u16(), r.u16(), r.u16(), r.u8()
p["channels"], p["sample_rate"] = r.u8(), r.u32()
raw["gain_attr"], raw["pan_attr"] = r.short(), r.short()
p["max_tiles"], p["max_voices"] = r.u8(), r.u8()
body = b[:r.p]
c["sig"] = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
keys_ok = all(v == b"" or is_key(v) for v in raw.values())
for k, v in raw.items():
p[k] = v.decode("latin-1")
p["omit_stopped"] = omit == 1
if not canvas_ok(p):
raise Refuse("canvas_out_of_range")
if not keys_ok or omit > 1 or not fields_ok(p):
raise Refuse("compose_invalid")
bad = ED.verify(c["signer"], DESCRIPTOR_DOMAIN + body, c["sig"])
if bad:
raise Refuse(bad)
c["params"] = p
c["digest"] = sha(body)
return c
def fields_of(c):
out = {"room": c["room"].hex(), "signer": c["signer"].hex(), "effective": c["effective"],
"compositor": c["compositor"].hex(), "background": c["background"].hex(), "layer": c["layer"]}
for k in PARAM_KEYS:
out[k] = c["params"][k]
return out
# ---------------------------------------------------------------- §4.3 attributes
def decimal(v):
"""A decimal integer of §4.3, or None."""
neg = v.startswith("-")
digits = v[1:] if neg else v
if not 1 <= len(digits) <= 10 or any(ch not in "0123456789" for ch in digits):
return None
if digits[0] == "0" and (len(digits) > 1 or neg):
return None
n = int(digits)
return -n if neg else n
def read_attributes(p, attrs):
"""§4.3 for one participant: (featured, order, pip, gain, pan), or Refuse."""
def look(key):
if key == "":
return None
return next((v for k, v in attrs if k == key), None)
def boolean(key):
v = look(key)
if v is None or v == "false":
return False
if v == "true":
return True
raise Refuse("attr_malformed")
def ranged(key, lo, hi, absent):
v = look(key)
if v is None:
return absent
n = decimal(v)
if n is None or not lo <= n <= hi:
raise Refuse("attr_malformed")
return n
featured = boolean(p["featured_attr"])
order = ranged(p["order_attr"], -2 ** 31, 2 ** 31 - 1, None)
pip = boolean(p["pip_attr"])
gain = ranged(p["gain_attr"], 0, 16384, 4096)
pan = ranged(p["pan_attr"], -4096, 4096, 0)
return featured, order, pip, gain, pan
# ---------------------------------------------------------------- §5 the layout
def fit(bw, bh, a, b):
"""§5.1."""
if bw * b <= bh * a:
return bw, max(1, bw * b // a)
return max(1, bh * a // b), bh
def centred(outer, w, h):
x, y, ow, oh = outer
return (x + (ow - w) // 2, y + (oh - h) // 2, w, h)
def reference(aspect):
return {SQUARE: (1, 1), PORTRAIT: (3, 4)}.get(aspect, (16, 9))
def grid(region, n, gap, aspect):
"""§5.3: n tile rectangles."""
X, Y, W, H = region
a, b = reference(aspect)
chosen = None
for c in range(1, n + 1):
r = -(-n // c)
if (c - 1) * gap >= W or (r - 1) * gap >= H:
continue
cw, ch = (W - (c - 1) * gap) // c, (H - (r - 1) * gap) // r
if cw < 1 or ch < 1:
continue
tw, th = fit(cw, ch, a, b)
if chosen is None or tw * th >= chosen[0]:
chosen = (tw * th, c, r, cw, ch)
_, c, r, cw, ch = chosen
y0 = Y + (H - (r * ch + (r - 1) * gap)) // 2
out = []
for k in range(n):
i, j = k // c, k % c
m = n - (r - 1) * c if i == r - 1 else c
x0 = X + (W - (m * cw + (m - 1) * gap)) // 2
cell = (x0 + j * (cw + gap), y0 + i * (ch + gap), cw, ch)
out.append(cell if aspect == AUTO else centred(cell, *fit(cw, ch, a, b)))
return out
def show(tile, size, mode):
"""§5.6: (destination, source) of a source of `size` in `tile`."""
tx, ty, tw, th = tile
sw, sh = size
wider = sw * th >= sh * tw
whole = (0, 0, sw, sh)
if mode == FILL:
return tile, whole
if mode == CONTAIN:
if wider:
dh = max(1, tw * sh // sw)
return (tx, ty + (th - dh) // 2, tw, dh), whole
dw = max(1, th * sw // sh)
return (tx + (tw - dw) // 2, ty, dw, th), whole
if wider:
cw = max(1, sh * tw // th)
return tile, ((sw - cw) // 2, 0, cw, sh)
ch = max(1, sw * th // tw)
return tile, (0, (sh - ch) // 2, sw, ch)
def layout(p, entrants):
"""§5 and §6's voices over entrants (principal, join, video, audio, attrs): a dict."""
check_params(p)
members = []
for e in entrants:
featured, order, pip, gain, pan = read_attributes(p, e["attrs"])
members.append(dict(e, featured=featured, order=order, pip=pip, gain=gain, pan=pan))
# §4.4
members.sort(key=lambda m: (m["order"] is None, m["order"] or 0, m["join"], m["principal"]))
voices = [{"principal": m["principal"].hex(), "gain": m["gain"], "pan": m["pan"]}
for m in members if m["audio"]][:p["max_voices"]]
# §5.2
shown = [m for m in members if not (p["omit_stopped"] and m["video"] is None)]
pip_m = next((m for m in shown if m["pip"]), None) if p["layout"] == PIP else None
rest = [m for m in shown if m is not pip_m]
f = next((i for i, m in enumerate(rest) if m["featured"]), None)
if f is not None:
rest.insert(0, rest.pop(f))
rest = rest[:p["max_tiles"] - (1 if pip_m is not None else 0)]
featured = f is not None and bool(rest)
window = pip_m
if pip_m is not None and not rest and p["behaviour"] == DYNAMIC:
rest, featured, window = [pip_m], pip_m["featured"], None
tiles = grid_tiles(p, rest, featured)
if window is not None:
tiles.append(pip_tile(p, window))
return {"order": [m["principal"].hex() for m in members], "tiles": tiles, "voices": voices}
def tile_of(p, m, rect, place, z):
mode = p["fill"] if p["fill"] != DEFAULT else (CONTAIN if place == "featured" else COVER)
out = {"principal": m["principal"].hex(), "place": place, "z": z, "tile": list(rect), "dst": None, "src": None}
if m["video"] is not None:
dst, src = show(rect, m["video"], mode)
out["dst"], out["src"] = list(dst), list(src)
return out
def grid_tiles(p, ms, featured):
"""§5.3, §5.4."""
w, h, g = p["width"], p["height"], p["gap"]
if not ms:
return []
if not featured:
return [tile_of(p, m, r, "grid", 0) for m, r in zip(ms, grid((0, 0, w, h), len(ms), g, p["aspect"]))]
if len(ms) == 1:
return [tile_of(p, ms[0], (0, 0, w, h), "featured", 0)]
mh = (h - g) * 3 // 4
out = [tile_of(p, ms[0], (0, 0, w, mh), "featured", 0)]
band = (0, mh + g, w, h - mh - g)
out += [tile_of(p, m, r, "grid", 0) for m, r in zip(ms[1:], grid(band, len(ms) - 1, g, p["aspect"]))]
return out
def pip_tile(p, m):
"""§5.5."""
w, h, o = p["width"], p["height"], p["offset"]
if p["pip_width"] > 0 and p["pip_height"] > 0:
pw, ph = p["pip_width"], p["pip_height"]
else:
a, b = m["video"] if m["video"] is not None else (w, h)
if p["pip_width"] > 0:
bw = p["pip_width"]
elif p["pip_height"] > 0:
bw = w - o
else:
bw = min(w // 4, w - o)
bh = p["pip_height"] if p["pip_height"] > 0 else h - o
pw, ph = fit(bw, bh, a, b)
x = {1: o, 2: w - o - pw, 3: o, 4: w - o - pw}[p["corner"]]
y = {1: o, 2: o, 3: h - o - ph, 4: h - o - ph}[p["corner"]]
return tile_of(p, m, (x, y, pw, ph), "pip", 1)
# ---------------------------------------------------------------- §6 the mix
def mix(channels, frames, inputs):
"""§6: the mixed samples, or Refuse."""
if channels not in (1, 2):
raise Refuse("mix_input_malformed")
for v in inputs:
if v["channels"] not in (1, 2) or not 0 <= v["gain"] <= 16384 or not -4096 <= v["pan"] <= 4096 \
or len(v["pcm"]) != frames * v["channels"]:
raise Refuse("mix_input_malformed")
sums = [0] * (frames * channels)
for v in inputs:
x, g, ch = v["pcm"], v["gain"], v["channels"]
if channels == 1:
for f in range(frames):
sums[f] += (x[f] * 4096 if ch == 1 else (x[2 * f] + x[2 * f + 1]) * 2048) * g
else:
al, ar = 4096 - max(v["pan"], 0), 4096 + min(v["pan"], 0)
for f in range(frames):
left, right = (x[f], x[f]) if ch == 1 else (x[2 * f], x[2 * f + 1])
sums[2 * f] += left * g * al
sums[2 * f + 1] += right * g * ar
return [min(32767, max(-32768, (s + (1 << 23)) >> 24)) for s in sums]
def pcm_of(h):
b = bytes.fromhex(h)
if len(b) % 2:
raise ValueError("odd PCM")
return list(struct.unpack("<%dh" % (len(b) // 2), b))
def pcm_hex(samples):
return struct.pack("<%dh" % len(samples), *samples).hex()
# ---------------------------------------------------------------- §4 the room at an instant
def render_field(f):
"""MOQT §8.8."""
return "".join(chr(c) if (chr(c).isascii() and chr(c).isalnum()) or c == 0x5F else ".%02x" % c for c in f.encode())
def track_digest(room_id, owner, media, name):
"""live-events §3.1's anchor track: the rendered namespace, a zero byte, the name."""
ns = "-".join(render_field(f) for f in (room_id.hex(), owner, MEDIA_NAME[media]))
return sha(ns.encode(), b"\x00", name.encode())
class RoomView:
"""A room that verified (§4.1), its replay stepped record by record."""
def __init__(self, d, held, steps, final):
self.d, self.held, self.steps, self.final = d, held, steps, final
def grant(self, digest):
return next(g for g in self.held if g["digest"] == digest)
def at(self, t):
"""The room at t: the state before the first record of the merged order past t."""
return next((s for rt, s in self.steps if rt > t), self.final)
def snapshot(room, joins):
return {"present": dict(room.present), "own": dict(room.own), "slots": copy.deepcopy(room.slots), "joins": dict(joins)}
def view_room(room):
"""live-rooms §9 over the room, stepped: a RoomView, or (reason, place)."""
descriptor = bytes.fromhex(room["descriptor"])
grants = [bytes.fromhex(g) for g in room["grants"]]
head = room["key_log_head"]
chains = [[bytes.fromhex(r) for r in c] for c in room["chains"]]
try:
d = LRV.read_descriptor(descriptor)
except Refuse as e:
return e.reason, ["room", "descriptor"]
try:
revoked = LRV.check_key_log(d, head)
except Refuse as e:
return e.reason, ["room", "key_log"]
held, bad = LRV.check_grants(d, grants)
if bad:
return bad[1], ["room", "grant", bad[0]]
state = LRV.Room(d, held, revoked)
auth = LRV.authority_policy()
ver = LEV.Verifier(d["room"], auth, {})
seen, joins, steps, at_index = {}, {}, [], []
numbered = 0
for c, i in LRV.merged_order(d, chains):
b = chains[c][i]
at_index.append((c, i))
try:
ev = LEV.read_record(b)
duplicate = ev["digest"] in seen
except Refuse:
ev, duplicate = None, False
if ev is not None:
steps.append((ev["t"], snapshot(state, joins)))
try:
LRV.step(d, state, ver, seen, auth, b)
except Refuse as e:
return e.reason, ["room", "record", c, i]
if not duplicate and LRV.is_room_key(d, ev["principal"]) and ev["kind"] == JOIN:
joins[ver.accepted[-1][2]["subject"]] = numbered
numbered += 1
if ver.gaps:
if any(LRV.is_room_key(d, p) for p, _, _ in ver.gaps):
return "lifecycle_incomplete", ["room"]
else:
mismatch = LEV.stated_tally_mismatch(ver.accepted)
if mismatch is not None:
c, i = at_index[mismatch]
return "tally_mismatch", ["room", "record", c, i]
return RoomView(d, held, steps, snapshot(state, joins))
def source(view, c, profile, layers, slot, media):
"""§4.2: a source and its composed layer."""
prof = view.d["profiles"][profile]
layer = min(layers - 1, c["layer"])
w, h = prof["layers"][layer][0], prof["layers"][layer][1]
return {"slot": slot, "media": media, "profile": profile, "layers": layers, "layer": layer,
"layering": prof["layering"], "width": w, "height": h}
def tracks(view, s, principal):
"""§8.2: the tracks a source is read from."""
owner = principal.hex() if s["slot"] == "" else "_" + s["slot"]
if s["layering"] == SIMULCAST:
names = [s["layer"]]
elif s["layering"] == STAGED:
names = list(range(s["layer"] + 1))
else:
names = [0]
return [track_digest(view.d["room"], owner, s["media"], str(n)) for n in names]
def participants(view, c, t):
"""§4.2 at t, in ascending order of key."""
st = view.at(t)
out = []
slots = list(zip(view.d["slots"], st["slots"]))
def own(p, m):
h = st["own"].get((p, m))
return None if h is None else source(view, c, h[0], h[1], "", m)
def live_in(p, kinds):
for s, ss in slots:
if ss["live"] and ss["live"][0] == p and s["media"] in kinds:
return source(view, c, ss["live"][1], ss["live"][2], s["name"].decode(), s["media"])
return None
for p in sorted(st["present"]):
if p == c["compositor"]:
continue
if not (any(q == p for q, _ in st["own"]) or any(ss["live"] and ss["live"][0] == p for _, ss in slots)):
continue
video = own(p, VIDEO) or own(p, SCREEN) or live_in(p, (VIDEO, SCREEN))
audio = own(p, AUDIO) or live_in(p, (AUDIO,))
g = view.grant(st["present"][p])
out.append({"principal": p, "grant": g["digest"], "join": st["joins"][p],
"attrs": LRV.read_attrs(g["attrs"]), "video": video, "audio": audio})
return out
def set_digest(view, t, ps):
"""§4.5."""
def src(s):
if s is None:
return b"\x00"
slot = s["slot"].encode()
return bytes([s["media"], len(slot)]) + slot + bytes([s["profile"], s["layers"], s["layer"]]) \
+ struct.pack("<HH", s["width"], s["height"])
out = SET_DOMAIN + view.d["room"] + struct.pack("<QI", t, len(ps))
for p in ps:
out += p["principal"] + p["grant"] + struct.pack("<I", p["join"]) + src(p["video"]) + src(p["audio"])
return sha(out)
def frame(view, c, t):
"""§4 and §5 at t: the frame as the corpus writes it, with what §8.2 reads."""
if t < c["effective"]:
raise Refuse("not_yet_effective")
ps = participants(view, c, t)
entrants = [{"principal": p["principal"], "join": p["join"],
"video": None if p["video"] is None else (p["video"]["width"], p["video"]["height"]),
"audio": p["audio"] is not None, "attrs": p["attrs"]} for p in ps]
lay = layout(c["params"], entrants)
def src_json(s, principal):
if s is None:
return None
return dict(s, tracks=[x.hex() for x in tracks(view, s, principal)])
out = {"t": t, "participants": [{"principal": p["principal"].hex(), "grant": p["grant"].hex(), "join": p["join"],
"attrs": [[k, v] for k, v in p["attrs"]],
"video": src_json(p["video"], p["principal"]),
"audio": src_json(p["audio"], p["principal"])} for p in ps],
"set_digest": set_digest(view, t, ps).hex()}
out.update(lay)
by = {p["principal"].hex(): p for p in ps}
inputs = {VIDEO: [], AUDIO: []}
for tl in lay["tiles"]:
if tl["dst"] is not None:
p = by[tl["principal"]]
inputs[VIDEO] += [(p["principal"], x) for x in tracks(view, p["video"], p["principal"])]
for v in lay["voices"]:
p = by[v["principal"]]
inputs[AUDIO] += [(p["principal"], x) for x in tracks(view, p["audio"], p["principal"])]
return out, inputs
def check_descriptor(view, b):
"""§3.3, §3.5."""
c = read_descriptor(b)
if c["room"] != view.d["room"]:
raise Refuse("wrong_room")
if c["signer"] != view.d["authority"]:
st = view.at(c["effective"])
g = st["present"].get(c["signer"])
if g is None or view.grant(g)["powers"] & COMPOSE_POWER == 0:
raise Refuse("compose_not_permitted")
return c
# ---------------------------------------------------------------- §8 the receipt
def read_object(r):
return {"track": r.take(32), "group": r.u64(), "object": r.u64(), "digest": r.take(32)}
def read_receipt(b):
"""§8.1: the receipt, or Refuse."""
read_head(b, b"WCR1")
r = Reader(b, 5)
rc = {"room": r.take(32), "compositor": r.take(32), "descriptor": r.take(32), "t": r.u64(), "state": r.take(32),
"media": r.u8(), "output": read_object(r)}
rc["inputs"] = [(r.take(32), read_object(r)) for _ in range(r.u8())]
body = b[:r.p]
rc["sig"] = r.take(64)
if r.p != len(b):
raise Refuse("malformed")
pairs = [(p, o["track"]) for p, o in rc["inputs"]]
if rc["media"] not in (AUDIO, VIDEO) or len(set(pairs)) != len(pairs):
raise Refuse("receipt_invalid")
bad = ED.verify(rc["compositor"], RECEIPT_DOMAIN + body, rc["sig"])
if bad:
raise Refuse(bad)
rc["digest"] = sha(body)
return rc
def verify_receipt(view, dbytes, rbytes):
"""§8.3: what the corpus states for a receipt case, the room already viewed."""
try:
c = check_descriptor(view, dbytes)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["descriptor"]}
def no(reason):
return {"outcome": "refuse", "reason": reason, "at": ["receipt"]}
try:
rc = read_receipt(rbytes)
except Refuse as e:
return no(e.reason)
if rc["room"] != view.d["room"]:
return no("wrong_room")
if rc["descriptor"] != c["digest"]:
return no("descriptor_mismatch")
if rc["t"] < c["effective"]:
return no("not_yet_effective")
st = view.at(rc["t"])
g = st["present"].get(rc["compositor"])
if c["compositor"] == ZERO or rc["compositor"] != c["compositor"] or g is None \
or view.grant(g)["powers"] & COMPOSE_POWER == 0:
return no("compositor_not_permitted")
held = st["own"].get((rc["compositor"], rc["media"]))
if held is None or rc["output"]["track"] not in [
track_digest(view.d["room"], rc["compositor"].hex(), rc["media"], str(n)) for n in range(held[1])]:
return no("output_not_published")
try:
f, inputs = frame(view, c, rc["t"])
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": ["set", rc["t"]]}
if rc["state"].hex() != f["set_digest"]:
return no("state_mismatch")
if [(p, o["track"]) for p, o in rc["inputs"]] != inputs[rc["media"]]:
return no("inputs_mismatch")
return {"outcome": "accept", "digest": rc["digest"].hex(), "set_digest": f["set_digest"]}
# ---------------------------------------------------------------- the corpus
def refuse(reason, at=None):
out = {"outcome": "refuse", "reason": reason}
if at is not None:
out["at"] = at
return out
def run_layout(v):
p = v["params"]
entrants = [{"principal": bytes.fromhex(e["principal"]), "join": e["join"],
"video": None if e["video"] is None else tuple(e["video"]), "audio": e["audio"],
"attrs": [tuple(kv) for kv in e["attrs"]]} for e in v["entrants"]]
try:
return dict(outcome="layout", **layout(p, entrants))
except Refuse as e:
return refuse(e.reason)
def run_mix(v):
inputs = [{"channels": x["channels"], "gain": x["gain"], "pan": x["pan"], "pcm": pcm_of(x["pcm"])} for x in v["inputs"]]
try:
return {"outcome": "mix", "pcm": pcm_hex(mix(v["channels"], v["frames"], inputs))}
except Refuse as e:
return refuse(e.reason)
def run_descriptor(v):
try:
c = read_descriptor(bytes.fromhex(v["bytes"]))
except Refuse as e:
return refuse(e.reason)
return {"outcome": "read", "digest": c["digest"].hex(), "fields": fields_of(c)}
class Rooms:
"""Each room of the corpus viewed once, by its content."""
def __init__(self):
self.cache = {}
def get(self, room):
k = json.dumps(room, sort_keys=True)
if k not in self.cache:
self.cache[k] = view_room(room)
return self.cache[k]
def run_composition(v, rooms, doc):
view = rooms.get(doc["rooms"][v["room"]])
if not isinstance(view, RoomView):
return refuse(view[0], view[1])
try:
c = check_descriptor(view, bytes.fromhex(v["descriptor"]))
except Refuse as e:
return refuse(e.reason, ["descriptor"])
frames = []
for t in v["instants"]:
try:
frames.append(frame(view, c, t)[0])
except Refuse as e:
return refuse(e.reason, ["set", t])
return {"outcome": "compose", "digest": c["digest"].hex(), "frames": frames}
def run_receipt(v, rooms, doc):
view = rooms.get(doc["rooms"][v["room"]])
if not isinstance(view, RoomView):
return refuse(view[0], view[1])
return verify_receipt(view, bytes.fromhex(v["descriptor"]), bytes.fromhex(v["receipt"]))
SINGULAR = {"layouts": "layout", "mixes": "mix", "descriptors": "descriptor", "compositions": "composition",
"receipts": "receipt"}
SECTIONS = [("layouts", lambda v, rooms, doc: run_layout(v)), ("mixes", lambda v, rooms, doc: run_mix(v)),
("descriptors", lambda v, rooms, doc: run_descriptor(v)), ("compositions", run_composition),
("receipts", run_receipt)]
def check(doc, rooms, quiet=False):
"""Every case against its statement: the failures."""
fails = []
for section, run in SECTIONS:
for v in doc[section]:
try:
got = run(v, rooms, doc)
except Exception as e: # a crash on a mutated copy is a failure to read it
got, problems = None, ["the case does not verify: %s: %s" % (type(e).__name__, e)]
else:
problems = [k for k in got if v.get(k) != got[k]]
problems += [k for k in ("outcome", "reason", "at") if k in v and k not in got]
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
print(("PASS" if not problems else "FAIL") + " %s %s: %s" % (SINGULAR[section], v["name"], what))
for k in problems:
fails.append("%s %s: %s is %s, the corpus states %s" % (
SINGULAR[section], v["name"], k, json.dumps(got.get(k))[:200] if got else k, json.dumps(v.get(k))[:200]))
return fails
def flip(h, at, bit=0x01):
b = bytearray(bytes.fromhex(h))
b[at] ^= bit
return b.hex()
def mutations(doc):
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
idx = {s: {v["name"]: i for i, v in enumerate(doc[s])} for s, _ in SECTIONS}
def case(d, section, name):
return d[section][idx[section][name]]
def mutate(label, f):
d = copy.deepcopy(doc)
f(d)
return label, d
def frame_at(d, name, t):
return next(f for f in case(d, "compositions", name)["frames"] if f["t"] == t)
def relay_drop(d):
relay = doc["principals"]["relay"]
chain = next(c for c in d["rooms"]["studio"]["chains"] if LEV.read_record(bytes.fromhex(c[0]))["principal"].hex() == relay)
chain.pop(5)
return [
mutate("grid_5: a tile one pixel right", lambda d: case(d, "layouts", "grid_5")["tiles"][4]["tile"].__setitem__(0, case(d, "layouts", "grid_5")["tiles"][4]["tile"][0] + 1)),
mutate("featured_4: two tiles swapped", lambda d: case(d, "layouts", "featured_4")["tiles"].reverse()),
mutate("pip_bottom_right_static: the window under the grid", lambda d: case(d, "layouts", "pip_bottom_right_static")["tiles"][-1].update(z=0)),
mutate("fill_cover: a source rectangle", lambda d: case(d, "layouts", "fill_cover")["tiles"][0]["src"].__setitem__(1, 1)),
mutate("fill_contain: a destination rectangle", lambda d: case(d, "layouts", "fill_contain")["tiles"][0]["dst"].__setitem__(3, 1)),
mutate("order_key_tie_break: two of the order swapped", lambda d: case(d, "layouts", "order_key_tie_break")["order"].reverse()),
mutate("order_attribute: an entrant's order", lambda d: case(d, "layouts", "order_attribute")["entrants"][2]["attrs"][0].__setitem__(1, "9")),
mutate("stopped_omitted: omit_stopped off", lambda d: case(d, "layouts", "stopped_omitted")["params"].update(omit_stopped=False)),
mutate("pip_alone_dynamic: stated static", lambda d: case(d, "layouts", "pip_alone_dynamic")["params"].update(behaviour=STATIC)),
mutate("attr_order_leading_zero: stated compose_invalid", lambda d: case(d, "layouts", "attr_order_leading_zero").update(reason="compose_invalid")),
mutate("voices: a gain", lambda d: case(d, "layouts", "voices")["voices"][0].update(gain=4097)),
mutate("sum_saturates_high: an output sample", lambda d: case(d, "mixes", "sum_saturates_high").update(pcm=flip(case(d, "mixes", "sum_saturates_high")["pcm"], 0))),
mutate("half_gain_ties: an input sample", lambda d: case(d, "mixes", "half_gain_ties")["inputs"][0].update(pcm=flip(case(d, "mixes", "half_gain_ties")["inputs"][0]["pcm"], 0))),
mutate("pan_hard_left: panned right", lambda d: case(d, "mixes", "pan_hard_left")["inputs"][0].update(pan=4096)),
mutate("descriptor grid: a byte of its body", lambda d: case(d, "descriptors", "grid").update(bytes=flip(case(d, "descriptors", "grid")["bytes"], 40))),
mutate("descriptor grid: its stated digest", lambda d: case(d, "descriptors", "grid").update(digest="00" * 32)),
mutate("descriptor canvas_too_wide: stated compose_invalid", lambda d: case(d, "descriptors", "canvas_too_wide").update(reason="compose_invalid")),
mutate("grid_session: a tile at 5 s", lambda d: frame_at(d, "grid_session", 5000000)["tiles"][1]["tile"].__setitem__(2, 1)),
mutate("grid_session: the set digest at 7 s", lambda d: frame_at(d, "grid_session", 7000000).update(set_digest="00" * 32)),
mutate("grid_session: a participant's join order", lambda d: frame_at(d, "grid_session", 4000000)["participants"][0].update(join=9)),
mutate("grid_session: a staged source's tracks reordered", lambda d: next(
p for p in frame_at(d, "grid_session", 4000000)["participants"] if p["video"] and len(p["video"]["tracks"]) > 1)["video"]["tracks"].reverse()),
mutate("grid_session: an instant moved", lambda d: case(d, "compositions", "grid_session")["instants"].__setitem__(4, 4500000)),
mutate("pip_session: a voice's pan", lambda d: frame_at(d, "pip_session", 6000000)["voices"][0].update(pan=1)),
mutate("studio: a lifecycle record not delivered", relay_drop),
mutate("studio: a byte of a grant", lambda d: d["rooms"]["studio"]["grants"].__setitem__(1, flip(d["rooms"]["studio"]["grants"][1], 200))),
mutate("receipt video: a byte of it", lambda d: case(d, "receipts", "video").update(receipt=flip(case(d, "receipts", "video")["receipt"], 150))),
mutate("receipt video: its stated digest", lambda d: case(d, "receipts", "video").update(digest="00" * 32)),
mutate("receipt inputs_missing: stated accepted", lambda d: case(d, "receipts", "inputs_missing").update(outcome="accept")),
mutate("receipt state_stale: stated inputs_mismatch", lambda d: case(d, "receipts", "state_stale").update(reason="inputs_mismatch")),
]
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
if st:
fails.append("ed25519 self-test")
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
rooms = Rooms()
fails += check(doc, rooms)
seen = {v.get("reason") for s, _ in SECTIONS for v in doc[s]}
missing = [r for r in REASONS if r not in seen]
if missing:
fails.append("no case is refused %s" % ", ".join(missing))
print("%s every reason of §10 refuses a case" % ("PASS" if not missing else "FAIL"))
for label, mutated in mutations(doc):
caught = check(mutated, rooms, quiet=True)
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:120] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for f in fails:
print("FAIL " + f)
total = sum(len(doc[s]) for s, _ in SECTIONS)
print(("PASS" if not fails else "FAIL") + " live-composition corpus: %d cases" % total)
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_compose_verify.py <live-conformance/compose>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -558,6 +558,20 @@
"edition": null,
"registered": "2026-06-12"
},
{
"capability": "wai.live.compose",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-17"
},
{
"capability": "wai.live.events",
"kind": "record",
@ -1685,6 +1699,13 @@
"section": "§5",
"status": "defined"
},
{
"name": "WCD1",
"kind": "magic",
"document": "extensions/live-compose.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WCN1",
"kind": "magic",
@ -1692,6 +1713,13 @@
"section": "§8.1",
"status": "defined"
},
{
"name": "WCR1",
"kind": "magic",
"document": "extensions/live-compose.md",
"section": "§8.1",
"status": "defined"
},
{
"name": "WEP1",
"kind": "magic",

View file

@ -522,6 +522,13 @@ wai:wai.image.png a wai:Capability ;
wai:specSection "§5 Mandatory floor" ;
wai:registeredOn "2026-06-12"^^xsd:date .
wai:wai.live.compose a wai:Capability ;
rdfs:label "wai.live.compose" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-17"^^xsd:date .
wai:wai.live.events a wai:Capability ;
rdfs:label "wai.live.events" ;
wai:kind wai:kind-record ;

View file

@ -192,6 +192,13 @@ live_room = ["live_events", "key_discovery"]
# with its single-take, aligned, complete and publishable verdicts. Ed25519, SHA-256 and
# the key log only; no new dependency.
capture_take = ["live_room"]
# Live composition (extensions/live-compose.md): `wai.live.compose` — the signed
# composition descriptor (WCD1), the participant set a room's lifecycle replay gives at
# each instant, the integer layout function (grid, featured tile, picture in picture, fill
# modes) and the saturating integer mixer, and the compositor's signed receipt (WCR1).
# The layout and the mixer use nothing beyond core and alloc. Ed25519 and SHA-256 only; no
# new dependency.
live_compose = ["live_room"]
# Object-based spatial audio (extensions/spatial-audio): the deterministic
# Fx mixdown engine. Reuses the world `Fx` floor + blake3; no codec/ML deps
# (sources arrive as decoded mono PCM — decoding the wai.audio.* assets is
@ -634,6 +641,11 @@ name = "wai_capture_take_vectors"
path = "src/bin/wai_capture_take_vectors.rs"
required-features = ["capture_take"]
[[bin]]
name = "wai_live_compose_vectors"
path = "src/bin/wai_live_compose_vectors.rs"
required-features = ["live_compose"]
[[bin]]
name = "wai_world_meter"
path = "src/bin/wai_world_meter.rs"
@ -1259,6 +1271,11 @@ name = "capture_take"
path = "tests/capture_take.rs"
required-features = ["capture_take"]
[[test]]
name = "live_compose"
path = "tests/live_compose.rs"
required-features = ["live_compose"]
[[test]]
name = "world_replay"
path = "tests/world_replay.rs"

View file

@ -0,0 +1,53 @@
//! Conformance corpus for live composition (`live-conformance/compose/`, extensions/live-compose.md §11).
//!
//! wai_live_compose_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_live_compose_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every case gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/live_compose_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::path::Path;
use wai::live_compose::corpus;
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_live_compose_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = doc == corpus::generate();
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -212,6 +212,7 @@ pub const CAP_VIDEO_CHANNEL: &str = "wai.video.channel";
pub const CAP_LIVE_EVENTS: &str = "wai.live.events";
pub const CAP_LIVE_ROOM: &str = "wai.live.room";
pub const CAP_CAPTURE_TAKE: &str = "wai.capture.take";
pub const CAP_LIVE_COMPOSE: &str = "wai.live.compose";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -292,6 +293,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_LIVE_EVENTS, S_INTERACTIVE, None, "2026-10-16"),
(CAP_LIVE_ROOM, S_INTERACTIVE, None, "2026-10-16"),
(CAP_CAPTURE_TAKE, S_INTERACTIVE, None, "2026-10-17"),
(CAP_LIVE_COMPOSE, S_INTERACTIVE, None, "2026-10-17"),
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_IVAS, S_IMMERSIVE, None, "2026-06-17"),
@ -338,7 +340,7 @@ const DERIVATIONS_REGISTERED: &str = "2026-09-28";
const RECORDS: &[&str] = &[
CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB,
CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -479,6 +481,7 @@ const PACKAGING: &str = "extensions/packaging.md";
const LIVE_EVENTS: &str = "extensions/live-events.md";
const LIVE_ROOMS: &str = "extensions/live-rooms.md";
const CAPTURE_BINDING: &str = "extensions/capture-binding.md";
const LIVE_COMPOSE: &str = "extensions/live-compose.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -487,8 +490,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// The wire identifiers of the container, of its two delivery bindings
/// (http-delivery, moq-streaming-format), of the key discovery those bindings serve
/// (jwp-receipts §7), of packaging, of live events, of live rooms and of capture binding, in document order. The
/// export lists them sorted by name and kind.
/// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding and
/// of live composition, in document order. The export lists them sorted by name and kind.
pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined),
wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined),
@ -542,6 +545,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WUR1", WireKind::Magic, CAPTURE_BINDING, "§6.2", WireStatus::Defined),
wire("WTM1", WireKind::Magic, CAPTURE_BINDING, "§7.1", WireStatus::Defined),
wire("WCN1", WireKind::Magic, CAPTURE_BINDING, "§8.1", WireStatus::Defined),
wire("WCD1", WireKind::Magic, LIVE_COMPOSE, "§3.1", WireStatus::Defined),
wire("WCR1", WireKind::Magic, LIVE_COMPOSE, "§8.1", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -406,6 +406,15 @@ pub mod live_room;
#[cfg(feature = "capture_take")]
pub mod capture_take;
// Live composition (extensions/live-compose.md): `wai.live.compose` — the signed
// composition descriptor, the participant set the room's lifecycle replay gives at each
// room-clock instant, the integer layout function and mixer that every sink runs alike,
// and the receipt a server compositor signs for a composed track. Rust-only, like the
// other live modules: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "live_compose")]
pub mod live_compose;
// Typed receipt classes (extensions/jwp-receipts.md §2.4.3–§2.4.12, §2.6, §3.1–§3.2):
// energy, lawful-basis, log-inclusion, soft-binding, binding-recovery, measurement,
// operating-point, stage, delivery-gap and reconstruct-session statements about

View file

@ -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<room::Reason> for Reason {
fn from(r: room::Reason) -> Self {
Reason::Room(r)
}
}
impl From<events::Reason> for Reason {
fn from(r: events::Reason) -> Self {
Reason::Room(room::Reason::Events(r))
}
}
impl From<Refusal> 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<At>),
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<const N: usize>(&mut self) -> Result<[u8; N], Reason> {
Ok(self.take(N)?.try_into().expect("N bytes"))
}
fn u8(&mut self) -> Result<u8, Reason> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16, Reason> {
Ok(u16::from_le_bytes(self.arr()?))
}
fn u32(&mut self) -> Result<u32, Reason> {
Ok(u32::from_le_bytes(self.arr()?))
}
fn u64(&mut self) -> Result<u64, Reason> {
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<u8>, 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<u8> {
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<u8> {
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<Compose, Reason> {
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<u8>],
pub head: Option<&'a str>,
pub chains: &'a [Vec<Vec<u8>>],
}
/// 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<VerifiedRoom<'a>, (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<Vec<Vec<u8>>> = 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<u8> = 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<u8>) {
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<Source>,
pub audio: Option<Source>,
}
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<Participant> {
let st = &at.state;
let d = &vr.d;
let live_in = |p: &[u8; 32], kinds: &[u8]| -> Option<Source> {
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<Participant>,
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<Frame, Reason> {
if t < c.effective {
return Err(Reason::NotYetEffective);
}
let participants = participants(c, vr, at);
let entrants: Vec<Entrant> = 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, Reason> {
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<Compose, Reason> {
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<Frame>),
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<InputRef>,
pub sig: [u8; 64],
}
fn put_object(o: &mut Vec<u8>, 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<ObjectRef, Reason> {
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<u8> {
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<u8> {
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<Receipt, Reason> {
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));
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<u32> = 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.is_multiple_of(2);
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<bool, Refusal> {
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<i64> {
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<i64, Refusal> {
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<i32>,
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<Read, Refusal> {
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<Video>,
}
/// One voice of the mix (§6): a participant with an audio source, its gain and its pan.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Voice {
pub principal: [u8; 32],
pub gain: u32,
pub pan: i32,
}
/// What the layout function gives (§5.7).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
/// Every participant, in the order of §4.4, before any is omitted.
pub order: Vec<[u8; 32]>,
/// The tiles, in drawing order: the grid's (the featured tile first), then the PiP
/// window.
pub tiles: Vec<Tile>,
/// The voices, in the order of §4.4.
pub voices: Vec<Voice>,
}
/// The aspect a grid is chosen by (§5.3), as `a`:`b`.
fn reference(a: u8) -> (u64, u64) {
match a {
aspect::SQUARE => (1, 1),
aspect::PORTRAIT => (3, 4),
_ => (16, 9),
}
}
/// The largest rectangle of aspect `a`:`b` within `bw` × `bh`, each side at least 1
/// (§5.1). `bw`, `bh`, `a`, `b` ≥ 1.
pub fn fit(bw: u64, bh: u64, a: u64, b: u64) -> (u64, u64) {
if bw * b <= bh * a {
(bw, max(1, bw * b / a))
} else {
(max(1, bh * a / b), bh)
}
}
fn centred(outer: Rect, w: u64, h: u64) -> Rect {
Rect::new(outer.x + (outer.w - w as u32) / 2, outer.y + (outer.h - h as u32) / 2, w as u32, h as u32)
}
/// §5.3: the tiles of a grid of `n` ≥ 1 over `region`.
fn grid(region: Rect, n: u64, gap: u64, asp: u8) -> Vec<Rect> {
let (a, b) = reference(asp);
let (rw, rh) = (region.w as u64, region.h as u64);
// (area, columns, rows, cell width, cell height) of the best candidate so far.
let mut best: Option<(u64, u64, u64, u64, u64)> = None;
for c in 1..=n {
let r = n.div_ceil(c);
if (c - 1) * gap >= rw || (r - 1) * gap >= rh {
continue;
}
let (cw, ch) = ((rw - (c - 1) * gap) / c, (rh - (r - 1) * gap) / r);
if cw == 0 || ch == 0 {
continue;
}
let (tw, th) = fit(cw, ch, a, b);
if best.is_none_or(|(area, ..)| tw * th >= area) {
best = Some((tw * th, c, r, cw, ch));
}
}
// §3.3's rule on the gap makes the square grid a candidate for every n it allows.
let Some((_, c, r, cw, ch)) = best else { return Vec::new() };
let y0 = region.y as u64 + (rh - (r * ch + (r - 1) * gap)) / 2;
(0..n)
.map(|k| {
let (i, j) = (k / c, k % c);
let in_row = if i == r - 1 { n - (r - 1) * c } else { c };
let x0 = region.x as u64 + (rw - (in_row * cw + (in_row - 1) * gap)) / 2;
let cell = Rect::new((x0 + j * (cw + gap)) as u32, (y0 + i * (ch + gap)) as u32, cw as u32, ch as u32);
if asp == aspect::AUTO {
cell
} else {
let (tw, th) = fit(cw, ch, a, b);
centred(cell, tw, th)
}
})
.collect()
}
/// §5.6: a source of `sw` × `sh` in `tile` by fill mode `mode` (not the default).
pub fn place_video(tile: Rect, sw: u32, sh: u32, mode: u8) -> Video {
let (tw, th) = (tile.w as u64, tile.h as u64);
let (sw, sh) = (max(1, sw) as u64, max(1, sh) as u64);
let whole = Rect::new(0, 0, sw as u32, sh as u32);
let wider = sw * th >= sh * tw;
match mode {
fill::FILL => Video { dst: tile, src: whole },
fill::CONTAIN => {
let dst = if wider {
let dh = max(1, tw * sh / sw);
Rect::new(tile.x, tile.y + ((th - dh) / 2) as u32, tile.w, dh as u32)
} else {
let dw = max(1, th * sw / sh);
Rect::new(tile.x + ((tw - dw) / 2) as u32, tile.y, dw as u32, tile.h)
};
Video { dst, src: whole }
}
_ => {
let src = if wider {
let cw = max(1, sh * tw / th);
Rect::new(((sw - cw) / 2) as u32, 0, cw as u32, sh as u32)
} else {
let ch = max(1, sw * th / tw);
Rect::new(0, ((sh - ch) / 2) as u32, sw as u32, ch as u32)
};
Video { dst: tile, src }
}
}
}
/// One participant, read.
struct Member<'a> {
e: &'a Entrant<'a>,
r: Read,
}
/// §4.4: an order attribute first, ascending; then join order; then the principal's key.
fn by_order(a: &Member, b: &Member) -> Ordering {
(a.r.order.is_none(), a.r.order.unwrap_or(0), a.e.join, a.e.principal).cmp(&(b.r.order.is_none(), b.r.order.unwrap_or(0), b.e.join, b.e.principal))
}
/// The layout function (§5) and the voices of the mix (§6) for `p`, over `entrants` in
/// any order. `p` is checked (§3.3) first.
pub fn layout(p: &Params, entrants: &[Entrant]) -> Result<Layout, Refusal> {
p.check()?;
let mut ms: Vec<Member> = Vec::with_capacity(entrants.len());
for e in entrants {
ms.push(Member { e, r: read_attrs(p, e.attrs)? });
}
ms.sort_by(by_order);
let order = ms.iter().map(|m| m.e.principal).collect();
let voices = ms
.iter()
.filter(|m| m.e.audio)
.take(p.max_voices as usize)
.map(|m| Voice { principal: m.e.principal, gain: m.r.gain, pan: m.r.pan })
.collect();
// §5.2: the tiles' participants.
let shown: Vec<&Member> = ms.iter().filter(|m| !p.omit_stopped || m.e.video.is_some()).collect();
let pip = if p.layout == kind::PIP { shown.iter().position(|m| m.r.pip) } else { None };
let pip_member = pip.map(|i| shown[i]);
let rest: Vec<&Member> = shown.iter().enumerate().filter(|(i, _)| Some(*i) != pip).map(|(_, m)| *m).collect();
let (rest, featured) = featured_first(rest, p.max_tiles as usize - pip.is_some() as usize);
let (on_grid, featured, window) = match pip_member {
// §5.5: alone and dynamic, the PiP participant is laid out as if it had no pip
// attribute.
Some(m) if rest.is_empty() && p.behaviour == behaviour::DYNAMIC => (vec![m], m.r.featured, None),
other => (rest, featured, other),
};
let mut tiles = grid_tiles(p, on_grid, featured);
if let Some(m) = window {
tiles.push(pip_tile(p, m));
}
Ok(Layout { order, tiles, voices })
}
/// §5.2: the featured participant (the first in order whose featured attribute is
/// `true`) moved first, then the list cut to `cap`, so that the cut never drops it; and
/// whether a featured participant is among those kept.
fn featured_first<'a, 'b>(mut members: Vec<&'b Member<'a>>, cap: usize) -> (Vec<&'b Member<'a>>, bool) {
let f = members.iter().position(|m| m.r.featured);
if let Some(i) = f {
let m = members.remove(i);
members.insert(0, m);
}
members.truncate(cap);
let kept = f.is_some() && !members.is_empty();
(members, kept)
}
fn video_of(p: &Params, m: &Member, tile: Rect, place: u8) -> Option<Video> {
let mode = match (p.fill, place) {
(fill::DEFAULT, place::FEATURED) => fill::CONTAIN,
(fill::DEFAULT, _) => fill::COVER,
(f, _) => f,
};
m.e.video.map(|(sw, sh)| place_video(tile, sw, sh, mode))
}
/// §5.3, §5.4: the grid's tiles over the canvas; when `featured`, `members[0]` is the
/// featured participant.
fn grid_tiles(p: &Params, members: Vec<&Member>, featured: bool) -> Vec<Tile> {
let n = members.len();
let (w, h, g) = (p.width as u64, p.height as u64, p.gap as u64);
let canvas = Rect::new(0, 0, p.width, p.height);
let mut out = Vec::with_capacity(n);
let mut put = |m: &Member, tile: Rect, place: u8| out.push(Tile { principal: m.e.principal, place, z: 0, tile, video: video_of(p, m, tile, place) });
if n == 0 {
return Vec::new();
}
if featured {
if n == 1 {
put(members[0], canvas, place::FEATURED);
} else {
let mh = main_height(h, g);
put(members[0], Rect::new(0, 0, p.width, mh as u32), place::FEATURED);
let band = Rect::new(0, (mh + g) as u32, w as u32, (h - mh - g) as u32);
for (m, t) in members[1..].iter().zip(grid(band, n as u64 - 1, g, p.aspect)) {
put(m, t, place::GRID);
}
}
} else {
for (m, t) in members.iter().zip(grid(canvas, n as u64, g, p.aspect)) {
put(m, t, place::GRID);
}
}
out
}
/// §5.5: the PiP window.
fn pip_tile(p: &Params, m: &Member) -> Tile {
let (w, h, o) = (p.width as u64, p.height as u64, p.offset as u64);
let (pw, ph) = if p.pip_width > 0 && p.pip_height > 0 {
(p.pip_width as u64, p.pip_height as u64)
} else {
let (a, b) = m.e.video.map_or((w, h), |(sw, sh)| (max(1, sw) as u64, max(1, sh) as u64));
let bw = if p.pip_width > 0 {
p.pip_width as u64
} else if p.pip_height > 0 {
w - o
} else {
min(w / 4, w - o)
};
let bh = if p.pip_height > 0 { p.pip_height as u64 } else { h - o };
fit(bw, bh, a, b)
};
let (x, y) = match p.corner {
corner::TOP_LEFT => (o, o),
corner::TOP_RIGHT => (w - o - pw, o),
corner::BOTTOM_LEFT => (o, h - o - ph),
_ => (w - o - pw, h - o - ph),
};
let tile = Rect::new(x as u32, y as u32, pw as u32, ph as u32);
Tile { principal: m.e.principal, place: place::PIP, z: 1, tile, video: video_of(p, m, tile, place::PIP) }
}
// ---------------------------------------------------------------------------------------
// §6 The audio mix
// ---------------------------------------------------------------------------------------
/// One voice's decoded audio for one block (§6): its channels (1 or 2), its gain and pan,
/// and its samples, interleaved when stereo.
#[derive(Clone, Copy, Debug)]
pub struct Input<'a> {
pub channels: u8,
pub gain: u32,
pub pan: i32,
pub pcm: &'a [i16],
}
/// §6: `frames` frames of `channels` (1 or 2) channels mixed from `inputs`, each exactly
/// `frames` frames of its own channels. `mix_input_malformed` otherwise.
pub fn mix(channels: u8, frames: usize, inputs: &[Input]) -> Result<Vec<i16>, Refusal> {
let bad = Err(Refusal::MixInputMalformed);
if !(1..=2).contains(&channels) {
return bad;
}
for v in inputs {
if !(1..=2).contains(&v.channels)
|| v.gain > MAX_GAIN
|| v.pan.unsigned_abs() > MAX_PAN as u32
|| Some(v.pcm.len()) != frames.checked_mul(v.channels as usize)
{
return bad;
}
}
let out_ch = channels as usize;
let mut acc = vec![0i64; frames * out_ch];
for v in inputs {
let g = v.gain as i64;
let s = |i: usize| v.pcm[i] as i64;
if out_ch == 1 {
for (f, a) in acc.iter_mut().enumerate() {
let x = if v.channels == 1 { s(f) * 4096 } else { (s(2 * f) + s(2 * f + 1)) * 2048 };
*a += x * g;
}
} else {
let left = (MAX_PAN - max(v.pan, 0)) as i64;
let right = (MAX_PAN + min(v.pan, 0)) as i64;
for f in 0..frames {
let (l, r) = if v.channels == 1 { (s(f), s(f)) } else { (s(2 * f), s(2 * f + 1)) };
acc[2 * f] += l * g * left;
acc[2 * f + 1] += r * g * right;
}
}
}
// Round half up by one arithmetic shift of the exact sum, then saturate once.
Ok(acc.iter().map(|&a| ((a + (1 << 23)) >> 24).clamp(i16::MIN as i64, i16::MAX as i64) as i16).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> Params {
Params {
width: 1920,
height: 1080,
fps: 30,
layout: kind::GRID,
gap: 0,
aspect: aspect::VIDEO,
fill: fill::DEFAULT,
featured_attr: "featured".into(),
order_attr: "order".into(),
omit_stopped: false,
pip_attr: String::new(),
corner: 0,
pip_width: 0,
pip_height: 0,
offset: 0,
behaviour: 0,
channels: 2,
sample_rate: 48_000,
gain_attr: "gain".into(),
pan_attr: "pan".into(),
max_tiles: 12,
max_voices: 12,
}
}
fn who(i: u8) -> [u8; 32] {
[i; 32]
}
fn rects(p: &Params, n: u8) -> Vec<Rect> {
featured_rects(p, n, None)
}
/// The tiles of `n` participants, the one at `featured` (if any) marked featured.
fn featured_rects(p: &Params, n: u8, featured: Option<u8>) -> Vec<Rect> {
let none: Vec<(String, String)> = Vec::new();
let star = vec![("featured".to_string(), "true".to_string())];
let es: Vec<Entrant> = (0..n)
.map(|i| Entrant { principal: who(i), join: i as u32, video: Some((1280, 720)), audio: true, attrs: if Some(i) == featured { &star } else { &none } })
.collect();
layout(p, &es).expect("a layout").tiles.iter().map(|t| t.tile).collect()
}
/// The grid choice of §5.3 on a 16:9 canvas with no gap: one tile fills it, two sit
/// side by side (the tie goes to more columns), three and four make two by two, and
/// ten to twelve four by three.
#[test]
fn grid_choice() {
let p = params();
assert_eq!(rects(&p, 1), vec![Rect::new(0, 0, 1920, 1080)]);
assert_eq!(rects(&p, 2), vec![Rect::new(0, 270, 960, 540), Rect::new(960, 270, 960, 540)]);
let three = rects(&p, 3);
assert_eq!(three[2], Rect::new(480, 540, 960, 540), "the last row is centred");
assert_eq!(rects(&p, 12)[11], Rect::new(1440, 765, 480, 270), "four by three cells of 480 by 360");
assert!(rects(&p, 0).is_empty());
}
/// Every tile of every grid of 1 to 64 tiles on each canvas, with and without a featured
/// participant, lies within the canvas, is at least a pixel each way, and no two tiles
/// overlap.
#[test]
fn tiles_stay_on_the_canvas() {
for (w, h, g) in [(1920, 1080, 8), (1080, 1920, 4), (1440, 1440, 0), (640, 360, 2)] {
let mut p = Params { width: w, height: h, gap: g, max_tiles: 64, max_voices: 64, ..params() };
for a in 0..=3 {
p.aspect = a;
for (n, f) in (0..=64u8).flat_map(|n| [(n, None), (n, n.checked_sub(1))]) {
let rs = featured_rects(&p, n, f);
assert_eq!(rs.len(), n as usize);
for (i, r) in rs.iter().enumerate() {
assert!(r.w >= 1 && r.h >= 1 && r.x + r.w <= w && r.y + r.h <= h, "{w}x{h} n={n} {r:?}");
for s in &rs[..i] {
assert!(r.x >= s.x + s.w || s.x >= r.x + r.w || r.y >= s.y + s.h || s.y >= r.y + r.h, "overlap {r:?} {s:?}");
}
}
}
}
}
}
/// Cover crops the source to the tile's aspect; contain letterboxes inside the tile;
/// fill stretches.
#[test]
fn fill_modes() {
let t = Rect::new(10, 20, 400, 400);
assert_eq!(place_video(t, 1920, 1080, fill::COVER), Video { dst: t, src: Rect::new(420, 0, 1080, 1080) });
assert_eq!(place_video(t, 1920, 1080, fill::CONTAIN), Video { dst: Rect::new(10, 107, 400, 225), src: Rect::new(0, 0, 1920, 1080) });
assert_eq!(place_video(t, 1920, 1080, fill::FILL), Video { dst: t, src: Rect::new(0, 0, 1920, 1080) });
assert_eq!(place_video(t, 1080, 1920, fill::CONTAIN).dst, Rect::new(97, 20, 225, 400));
}
#[test]
fn attributes() {
for (v, n) in [("0", 0), ("-7", -7), ("2147483647", 2_147_483_647), ("-2147483648", -2_147_483_648)] {
assert_eq!(read_int(v), Some(n), "{v}");
}
for v in ["", "-", "-0", "01", "+1", " 1", "1 ", "12345678901", "1e3", "٣"] {
assert_eq!(read_int(v), None, "{v:?}");
}
let p = params();
let a = |k: &str, v: &str| vec![(k.to_string(), v.to_string())];
assert_eq!(read_attrs(&p, &a("order", "2147483648")), Err(Refusal::AttrMalformed));
assert_eq!(read_attrs(&p, &a("featured", "TRUE")), Err(Refusal::AttrMalformed));
assert_eq!(read_attrs(&p, &a("gain", "16385")), Err(Refusal::AttrMalformed));
assert_eq!(read_attrs(&p, &a("pan", "-4096")).map(|r| r.pan), Ok(-4096));
assert_eq!(read_attrs(&p, &[]), Ok(Read { featured: false, order: None, pip: false, gain: UNITY_GAIN, pan: 0 }));
}
/// A single mono voice at unity gain and centre passes through both channels; a sum
/// that overflows saturates; the shift rounds half up.
#[test]
fn mixing() {
let x = [0i16, 1, -1, 32767, -32768, 1234];
let one = Input { channels: 1, gain: UNITY_GAIN, pan: 0, pcm: &x };
assert_eq!(mix(1, 6, &[one]).unwrap(), x.to_vec());
let st = mix(2, 6, &[one]).unwrap();
assert!(st.chunks(2).zip(x).all(|(lr, s)| lr == [s, s]));
let loud = [30000i16; 2];
let two = Input { channels: 1, gain: UNITY_GAIN, pan: 0, pcm: &loud };
assert_eq!(mix(1, 2, &[two, two]).unwrap(), vec![32767, 32767]);
let half = Input { channels: 1, gain: 2048, pan: 0, pcm: &[1, -1, 3, -3] };
assert_eq!(mix(1, 4, &[half]).unwrap(), vec![1, 0, 2, -1], "x/2 rounds half up");
let left = Input { channels: 1, gain: UNITY_GAIN, pan: -MAX_PAN, pcm: &[1000] };
assert_eq!(mix(2, 1, &[left]).unwrap(), vec![1000, 0]);
assert_eq!(mix(1, 3, &[one]), Err(Refusal::MixInputMalformed));
}
}

View file

@ -223,7 +223,7 @@ fn every_decoded_output_states_a_sha256() {
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
("live-conformance", "live events, rooms and takes: signed records, commitments and their verdicts, no decoded output"),
("live-conformance", "live events, rooms, takes and compositions: signed records, commitments and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),

View file

@ -0,0 +1,81 @@
//! `wai.live.compose` (extensions/live-compose.md): the committed corpus
//! `live-conformance/compose/vectors.json` regenerates byte for byte from its fixed seeds,
//! every case in it gets the outcome it states, every reason the extension adds refuses a
//! case of it, the cases §11 names are there, and the extension's §10 lists exactly the
//! reasons of the reference.
#![cfg(feature = "live_compose")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::live_compose::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> String {
std::fs::read_to_string(wai_dir().join("live-conformance/compose/vectors.json")).expect("the committed corpus")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
assert!(committed() == corpus::generate(), "live-conformance/compose/vectors.json differs from `wai_live_compose_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds refuses a case; grids of 0 to 12 tiles, every fill
/// mode, every corner under both behaviours, and both sessions are there; and the grid
/// session lays its room out anew at the guest's grant exchange.
#[test]
fn the_corpus_covers_every_reason_and_case() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut names: BTreeSet<String> = BTreeSet::new();
for section in ["layouts", "mixes", "descriptors", "compositions", "receipts"] {
for v in doc[section].as_array().unwrap() {
names.insert(v["name"].as_str().unwrap().to_string());
if let Some(r) = v["reason"].as_str() {
seen.insert(r.into());
}
}
}
for r in Reason::ALL {
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
}
let mut want: Vec<String> = (0..=12).map(|n| format!("grid_{n}")).collect();
want.extend(["fill_fill", "fill_cover", "fill_contain", "featured_4", "featured_absent", "order_key_tie_break", "stopped_omitted"].map(String::from));
for c in ["top_left", "top_right", "bottom_left", "bottom_right"] {
for b in ["static", "dynamic"] {
want.push(format!("pip_{c}_{b}"));
}
}
want.extend(["pip_alone_static", "pip_alone_dynamic", "sum_saturates_high", "grid_session", "pip_session", "video", "audio"].map(String::from));
for n in &want {
assert!(names.contains(n), "no case {n}");
}
let session = doc["compositions"].as_array().unwrap().iter().find(|v| v["name"] == "grid_session").unwrap();
let frames = session["frames"].as_array().unwrap();
let at = |t: u64| frames.iter().find(|f| f["t"] == t).unwrap();
assert_ne!(at(4_000_000)["order"], at(5_000_000)["order"], "the guest's exchange at 5 s reorders the room");
assert_ne!(at(4_000_000)["set_digest"], at(5_000_000)["set_digest"]);
}
/// §10 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/live-compose.md")).expect("the extension");
let at = text.find("\n## 10. Reasons").expect("a §10");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<String> = text[at..end]
.lines()
.filter(|l| l.starts_with("| `"))
.filter_map(|l| l.split('`').nth(1).map(String::from))
.collect();
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§10's table and Reason::ALL differ");
}