wai: timed text and live captions, wai.text.timed (L8) #303

Merged
dcharlot merged 28 commits from wai/timed-text into main 2026-10-18 00:37:23 -04:00
73 changed files with 19424 additions and 46 deletions

View file

@ -78,6 +78,12 @@ on:
# 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'
# tests/live_record.rs requires this extension's §9 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-recording.md'
# tests/timed_text.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/timed-text.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -127,6 +133,12 @@ on:
# 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'
# tests/live_record.rs requires this extension's §9 to list the codes the reference
# reports, and the registry's wire identifiers test reads its sections.
- 'wai/extensions/live-recording.md'
# tests/timed_text.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/timed-text.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -780,7 +792,8 @@ jobs:
# 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), and
# live_compose_verify.py (the live-compose corpus).
# live_compose_verify.py (the live-compose corpus), live_record_verify.py (the
# live-recording corpus), and timed_text_verify.py (the timed-text 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:
@ -971,6 +984,44 @@ jobs:
(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
# wai.live.recording (extensions/live-recording.md): the reference module's unit tests,
# the output tree's (whose range proofs it adds, tested against RFC 9162 §2.1.3.2's
# check), and tests/live_record.rs (the committed corpus regenerates byte for byte from
# its fixed seeds, wai-rs gives every recording and clip its stated outcome, every reason
# of §9 appears in it, every take it says it reuses is capture binding's byte for byte,
# and §9 lists exactly the reference's codes); then the corpus's second implementation,
# live_record_verify.py, written from the extension's text and sharing no code with
# wai-rs, which checks range proofs by the extension's recursion and by RFC 9162
# §2.1.3.2's own algorithm, verifies each take with capture_take_verify.py and every
# signature with WAI's strict Ed25519 (claim-conformance/_ed25519.py), and requires every
# mutated copy of the corpus to fail; standard library only, run isolated (-I).
- name: live recording · wai-rs and live_record_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_record --lib -- live_record:: output_tree::)
(cd wai-rs && cargo test --no-default-features --features live_record --test live_record)
python3 -I live-conformance/live_record_verify.py live-conformance/recordings
# wai.text.timed (extensions/timed-text.md): the reference module's unit tests (cue model,
# annotation, WebVTT, IMSC1 and its render-model bound, timelines, disclosure, and the
# 608 encoder and decoder) and tests/timed_text.rs (every file of the committed corpus,
# vectors.json and each expected .vtt, .ttml and .608.hex, regenerates byte for byte
# from its fixed seeds and no other file is there, wai-rs gives every case its stated
# outcome, the corpus covers every 608 code path, and §12 lists exactly the reference's
# codes); then the corpus's second implementation, timed_text_verify.py, written from
# the extension's text and sharing no code with wai-rs, which verifies every event with
# live_events_verify.py, writes every output byte for byte from the text, parses each
# IMSC1 document with xml.etree, and requires every mutated copy of the corpus to fail;
# standard library only, run isolated (-I).
- name: timed text · wai-rs and timed_text_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features timed_text --lib timed_text::)
(cd wai-rs && cargo test --no-default-features --features timed_text --test timed_text)
python3 -I live-conformance/timed_text_verify.py live-conformance/captions
# 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,92 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.text.timed`: timed text and live captions
- **The extension** ([timed-text](extensions/timed-text.md)) registers the record
`wai.text.timed`. A cue is a span of media time and its text in a closed set of styling,
italic runs, line breaks and six anchors, which WebVTT and the IMSC1 Text Profile both
express; text outside the subset both hold exactly (a tab, two spaces in a row, a space at
a line's edge) is refused rather than changed.
- **Live captions.** A caption is a `caption` event of `wai.live.events`, unchanged. A
machine caption is revised in place: each revision is a new `caption` event, followed by
a `meta` event carrying the `WTX1` annotation, which supersedes the revision before it by
digest, says whether the text is final and how much is stable, and carries the anchor and
italics. A verifier checks that revisions form one line per cue, keep their language,
anchor and stable prefix, and supersede only a partial of their own principal; it states
what a sink shows at an instant and the cues a stream resolves to. A publisher repeats
the open partial and each caption's annotation at a group's start, so a subscriber that
joins mid-stream shows what one from the start shows. live-events needs no change.
- **Mappings.** WebVTT documents and segments with `X-TIMESTAMP-MAP`, and a reader of the
subset; IMSC1 documents, refused when a sound check of the hypothetical render model does
not pass; the WebVTT and IMSC1 (cue mode) MoQ caption timelines, whose first object of
each group repeats every cue still showing, and their catalog entries; CTA-608 on field 1,
CC1, pop-on and roll-up with windows of 2, 3 and 4 rows, odd parity, doubled codes, the
special and extended characters with their fallbacks, and the slot clock's rate as a
parameter; CTA-708 is specified only.
- **The disclosure label.** Origin and model digest travel through every target: the HLS
`public.machine-generated` characteristic (`draft-pantos-hls-rfc8216bis-22` §4.4.6.1),
C2PA 2.4 `c2pa.ai-disclosure` and the IPTC digital source type, a WebVTT class span, IMSC1
attributes, timeline members, a catalog field, and a sidecar published by
`EXT-X-SESSION-DATA` for targets that cannot carry it, such as 608.
- **Player obligations** for captions under the viewer's control, persistent and
discoverable, each citing EN 301 549 V4.1.1 or WCAG 2.2.
- **Reference and corpus.** `wai-rs` feature `timed_text` (`src/timed_text.rs`, with
`cc608.rs`); `live-conformance/captions/`, written from fixed seeds by
`wai_timed_text_vectors` with every expected `.vtt`, `.ttml` and `.608.hex`, and checked
by `tests/timed_text.rs` and by `live-conformance/timed_text_verify.py`, a second
implementation that shares no code with `wai-rs`, parses every IMSC1 document with an XML
parser, and requires every mutated copy of the corpus to fail. The registry lists the
record, the magic `WTX1`, its catalog fields and its session data id.
- **Not checked against the standard.** CTA-608-E and CTA-708-E could not be read for this
revision. The 608 tables are as 47 CFR 79.101 reproduces them; the extended characters are
not there, and the extension marks its table of them for checking.
### `wai.live.recording`: a room session as one package, and clips that prove what they are
- **The extension** ([live-recording](extensions/live-recording.md)) registers the record
`wai.live.recording`. A finished room session is one WAI package: a directory of files and
its `LISTING` (SPEC §3.1), as a packaging package is, whose components are the room (its
descriptor, grants and key-log head), its events (each principal's chain whole, the form
live-events §8.6 gives a recording), one component for each participant's take (its `WTM1`
manifest, the chunks the store holds, its receipts and consents) and, optionally, the
composite by its composition receipts. Each component is named as SPEC §7.1 names one, by
an `id` and a `sha256`, here the SHA-256 of the listing of its files.
- **The recording manifest** (`WRM1`) is signed by the room authority, or by a principal that
held the record power at every instant of the session span (its grant in effect from the
span's start, with no leave, exchange or expiry taking the power away before its end; a
refusal states the first instant without it), and names every component with the span on
the room clock and the region the package is kept in, held to the room's region pin. Every
take the package holds lies within the span; the package holds the room's chains whole. A
recording verifier replays the room and each take and gives `complete` (every take the
room's events open is held, each one `complete` with its commitments unbroken from open to
close, and no event missing) and `publishable` for each purpose.
- **Clips.** A clip record (`WCL1`) covers a span of one take, by chunks or by a byte range
of the master (the boundary chunks carried whole, with trims), or one window of the room
clock across several takes. Each part carries its chunks' descriptors and one range proof
against a commitment the device made while it captured, the closing one or, while the take
runs, the one that covers the span. A clip verifier needs the clip's bytes, the record, the
take's commitments up to that one, the device's key and the room descriptor, never the take,
and checks `single-take` over the span. An aligned clip states each part's window on its
device's clock and its bound: the jitter to the room clock, twice it between two takes.
- **Range proofs.** The output tree gains `range_proof` and `verify_range`: RFC 6962's audit
path taken over a run of leaves, whose one-leaf case is the audit path node for node and
whose check agrees with RFC 9162 §2.1.3.2's, tested over every run of trees of up to 33
leaves. The take tree reuses it; no new tree.
- **Mappings.** The HLS and LL-CMAF presentation of a CMAF master (one Media Playlist per
take, its header by `EXT-X-MAP` byte range, parts proved with their chunk) and the MoQ
addressing of a recorded take's chunks for a FETCH (a group per chunk, a `LOCATION_FILTER`
per clip part) are specified; the reference states each clip part's FETCH address and
writes no playlist.
- **Reference and corpus.** `wai-rs` feature `live_record` (`src/live_record.rs`);
`live-conformance/recordings/`, written from fixed seeds by `wai_live_record_vectors`, whose
performer's take is capture binding's `lesson` take byte for byte, checked by
`tests/live_record.rs` and by `live-conformance/live_record_verify.py`, a second
implementation that shares no code with `wai-rs`, checks range proofs by the extension's
recursion and by RFC 9162's own algorithm, and requires every mutated copy of the corpus to
fail. The registry lists the record and its magics (`WRM1`, `WCL1`). live-events §8.6,
live-rooms §3.7 and capture-binding §1 point at the extension.
### `wai.live.compose`: a room's composition as an integer function
- **The extension** ([live-compose](extensions/live-compose.md)) registers the record

View file

@ -40,7 +40,8 @@ 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`, `wai.capture.take` and `wai.live.compose`).
`wai.live.room`, `wai.capture.take`, `wai.live.compose` and `wai.live.recording`; timed text,
`wai.text.timed`).
It names no payload format.
A derivation or record string is not a payload capability, so it is not
@ -62,8 +63,9 @@ 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), [capture-binding](extensions/capture-binding.md)
and [live-compose](extensions/live-compose.md) put on the wire outside a
[live-rooms](extensions/live-rooms.md), [capture-binding](extensions/capture-binding.md),
[live-compose](extensions/live-compose.md), [live-recording](extensions/live-recording.md)
and [timed-text](extensions/timed-text.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
@ -194,6 +196,7 @@ and a role are fixed once registered.
| `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.recording` | 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 |
| `wai.mesh.glb` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Interactive, temporal & computed content |
@ -234,6 +237,7 @@ and a role are fixed once registered.
| `wai.splat.spz` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Interactive, temporal & computed content |
| `wai.tensor.nnc` | payload | candidate | binary | standalone | patent-pool | standard-unclassified | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.ternary.tt` | payload | registered | binary | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
| `wai.text.timed` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.text.xz` | payload | registered | text | standalone | royalty-free | standard-defined | recommended | §5 Recommended modern set |
| `wai.text.zstd` | payload | registered | text | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
| `wai.video.av1` | payload | registered | video | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set |
@ -290,6 +294,7 @@ and a role are fixed once registered.
| `WAI2` | magic | defined | SPEC §7.1 |
| `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 |
| `WCD1` | magic | defined | live-compose §3.1 |
| `WCL1` | magic | defined | live-recording §5.1 |
| `WCN1` | magic | defined | capture-binding §8.1 |
| `WCR1` | magic | defined | live-compose §8.1 |
| `WEP1` | magic | defined | live-events §5 |
@ -297,8 +302,10 @@ and a role are fixed once registered.
| `WHP1` | magic | defined | http-delivery §6.2 |
| `WRD1` | magic | defined | live-rooms §3.1 |
| `WRG1` | magic | defined | live-rooms §4.1 |
| `WRM1` | magic | defined | live-recording §4.1 |
| `WSIX` | magic | defined | packaging §6 |
| `WTM1` | magic | defined | capture-binding §7.1 |
| `WTX1` | magic | defined | timed-text §4.2 |
| `WUR1` | magic | defined | capture-binding §6.2 |
| `X-WAI-EVENT` | playlist-attribute | defined | live-events §8.3 |
| `X-WAI-FLOOR` | playlist-attribute | defined | http-delivery §11.1 |
@ -309,10 +316,13 @@ and a role are fixed once registered.
| `science.transaction.wai.event` | playlist-attribute | defined | live-events §8.3 |
| `science.transaction.wai.multivariant` | data-id | defined | http-delivery §11.1 |
| `science.transaction.wai.stages` | data-id | defined | packaging §8 |
| `science.transaction.wai.timed-text` | data-id | defined | timed-text §10.5 |
| `tag:transaction.science,2026:wai/class` | scheme | defined | packaging §8 |
| `tag:transaction.science,2026:wai/floor` | scheme | defined | http-delivery §11.1 |
| `tag:transaction.science,2026:wai/http-delivery` | scheme | defined | http-delivery §11.1 |
| `tag:transaction.science,2026:wai/model/` | scheme | defined | timed-text §10.3 |
| `tag:transaction.science,2026:wai/stages` | scheme | defined | packaging §8 |
| `tag:transaction.science,2026:wai/timed-text` | scheme | defined | timed-text §10.4 |
| `urn:wai:event:1` | scheme | defined | live-events §8.4 |
| `urn:wai:event:1` | catalog | defined | live-events §8.1 |
| `wai` | catalog | defined | moq-streaming-format §3 |
@ -329,6 +339,9 @@ and a role are fixed once registered.
| `wai_capabilities` | catalog | defined | moq-streaming-format §4 |
| `wai_delivery_timeout_ms` | catalog | defined | packaging §8 |
| `wai_determinism` | catalog | defined | moq-streaming-format §4 |
| `wai_machine_generated` | catalog | defined | timed-text §7.4 |
| `wai_model` | catalog | defined | timed-text §7.2 |
| `wai_origin` | catalog | defined | timed-text §7.2 |
| `wai_receipt_alias` | catalog | defined | moq-streaming-format §4 |
| `wai_receipts` | catalog | defined | moq-streaming-format §4 |
| `wai_receipts_track` | catalog | defined | moq-streaming-format §4 |

View file

@ -592,8 +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`, `wai.capture.take` and `wai.live.compose`), which names no payload
format.
`wai.live.room`, `wai.capture.take`, `wai.live.compose` and `wai.live.recording`, and timed
text, `wai.text.timed`), 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
@ -911,6 +911,35 @@ compose power signs a receipt naming the descriptor, the set and the input and o
objects, which anyone checks against the same function:
[`extensions/live-compose.md`](extensions/live-compose.md).
And for a room session **recorded** whole — `wai.live.recording`: a finished session as one
package, a directory of files and its listing (§3.1), whose components are the room (its
descriptor, grants and key-log head), its events (each principal's chain whole), one take of
each participant and, optionally, the composite, each named as §7.1 names a component, by an
`id` and the SHA-256 of its files' listing. A recording manifest signed by the room authority,
or by a principal that held the record power at every instant of the session's span, names
every component with that span on the room clock and where the package is kept, and a verifier gives `complete` (every take of the
session held, each whole, and no event missing) and `publishable` for each purpose. A **clip**
of a recording, a span of one take or one window of several aligned on the room clock, is a
signed clip record carrying the covered chunks' descriptors and an RFC 6962 audit path taken
over their run of leaves against a commitment the device made while it captured, so a
verifier holding the clip, the take's commitments, the device's key and the room descriptor
knows the clip's bytes are exactly those bytes of the take, without the take. The mapping of
a recording onto HLS and LL-CMAF and the addressing of its chunks for a MoQ FETCH are field
semantics: [`extensions/live-recording.md`](extensions/live-recording.md).
And for the **captions** of a room — `wai.text.timed`: timed text, as cues in a closed set
of styling (italic runs, line breaks, six anchors) that WebVTT and the IMSC1 Text Profile
both express, so that a cue maps onto each exactly, and onto the two MoQ caption timelines,
CTA-608 (pop-on and roll-up on CC1) and HLS; where a target cannot hold a cue, the mapping
refuses it with a named reason. A live caption is a `caption` event of `wai.live.events`,
unchanged; a machine caption is revised in place, each revision a new `caption` event
followed by a `WTX1` annotation that supersedes the revision before it by digest, states
whether the text is final and how much of it is stable, and carries the anchor and italics.
The event's origin and model digest are the disclosure label, carried through every mapping
(an HLS media characteristic, C2PA's AI disclosure and digital source type, a WebVTT class,
IMSC1 attributes) or, where a target cannot hold it, in a sidecar:
[`extensions/timed-text.md`](extensions/timed-text.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

@ -35,8 +35,8 @@ claim (§9); the mapping from a room-clock instant to a position in the master,
bound (§10); and the mapping of a take and its CMAF output onto C2PA live video (§11).
Out of scope: a transport; the master's codec and container (a take commits to its bytes,
whatever they encode); how a store is operated; the room's own recording (a later
extension); per-object receipts of the relay that witness when a commitment was seen
whatever they encode); how a store is operated; the room's own recording
([live-recording](live-recording.md)); per-object receipts of the relay that witness when a commitment was seen
(a later extension); and what a party does with a verdict.
Relationship to the core spec: `wai.capture.take` is a **record** ([SPEC](../SPEC.md) §5):

View file

@ -585,8 +585,8 @@ extension, where a sender adds it, is experimental.
A recording holds each principal's chain whole: its records concatenated in `seq` order,
each record's length following from its own fields (§3.1). **Stream order** is that order.
In a sealed package the events are one component; packaging them is the recording
extension's, a later one. A recording verifies with the outcome accept, or states its gaps.
In a sealed package the events are one component, which
[live-recording](live-recording.md) §3.3 defines. A recording verifies with the outcome accept, or states its gaps.
## 9. Reasons

View file

@ -0,0 +1,703 @@
# WAI Extension: Live Recording
> Status: Draft. Registers the record `wai.live.recording`: a finished room session as one
> WAI package, and clips of it that prove what they are. The package is a directory of files
> and its listing, as a package of [packaging](packaging.md) §11 is; its components are the
> room (its descriptor, grants and key-log head, [live-rooms](live-rooms.md)), its events
> (each principal's chain whole, [live-events](live-events.md) §8.6), one component for
> each participant's take ([capture-binding](capture-binding.md)), and, optionally, the
> composite; and a signed **recording manifest** names each component by its digest, with
> the signer, the room and the session's span on the room clock. A recording verifier gives
> `complete` and `publishable` for each purpose their verdicts. A **clip** is a span of one
> take, or one window of several takes aligned on the room clock; its signed **clip record**
> carries, for each take, the descriptors of the chunks it covers and a range proof that
> they are leaves of a commitment the device made while it captured, so a verifier holding
> the clip, the take's commitments, the device's key and the room descriptor knows the
> clip's bytes are exactly those bytes of that take, unedited, without the take. The HLS and
> LL-CMAF presentation of a recording (§7) and the addressing of its chunks for a MoQ FETCH
> (§8) are mappings. No new transport and no new container. The reference implementation is
> the `live_record` module of `wai-rs` (feature `live_record`); the corpus is
> [`live-conformance/recordings/`](../live-conformance/recordings/), and its second
> implementation is `live-conformance/live_record_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: RFC 6962 §2.1 and RFC 9162 §2.1.1, §2.1.3 and §2.1.4 (the
> Merkle tree hash, inclusion proofs and their verification); RFC 8216 §3.3, §4.3.2.2,
> §4.3.2.5, §4.3.2.6, §4.3.3.1, §4.3.3.5 and §7; `draft-pantos-hls-rfc8216bis-22` §3.1.2,
> §4.4.3.7, §4.4.3.8, §4.4.4.5 and §4.4.4.9; and `draft-ietf-moq-transport-22` (MOQT)
> §2.4.1, §3.2, §3.2.1, §8.2, §8.7, §9.11 and §9.20.9. The two drafts are Internet-Drafts,
> and a section number of either in this text is that revision's.
## 1. Scope
In scope: the recording package, its files and its components (§3); the recording manifest
and recording verification, with the verdicts of `complete` and `publishable` (§4); the clip
record, the range proof it carries, clip verification, and cutting a clip by chunks, by
bytes or by a window of the room clock (§5); a clip across takes, its time correspondence
and its bound (§6); the mapping of a recording onto HLS and LL-CMAF (§7); and the addressing
of a recording's chunks for a MoQ FETCH (§8).
Out of scope: a transport; how a relay keeps, windows and serves a recording; a muxer for a
master that is not CMAF; the composition itself, which `wai.live.compose` defines
(live-compose §8, a later extension); and what a party does with a verdict.
Relationship to the core spec: `wai.live.recording` 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**, **room key**:
as in live-events §2 and live-rooms §2.
- **Take**, **device**, **master**, **chunk**, **descriptor**, **take tree**,
**commitment**, **offset**, **purpose**: as in capture-binding §2 and §4. *I* and *J* are
the room's commit interval and jitter in microseconds (capture-binding §2).
- **Session span**: the room-clock instants a recording covers and its signer vouches for,
`from` to `to`, both included.
- **Package**: the directory of files a recording is (§3).
- **Component**: a part of a package named by a kind, an `id` and a `sha256` (§3.2).
- **Head**: the commitment of a take a clip's proof is made against: one of the take's
`take_commit` events (capture-binding §5.1), its size and its root.
- **Part** (of a clip): the span of one take a clip covers (§5.1).
- **Cutter**: whoever makes a clip and signs its record.
Integers are little-endian. A digest is SHA-256 (FIPS 180-4), 32 bytes. Ed25519 is RFC
8032's, verified strictly (SPEC §7 *Signatures*).
## 3. The recording package
### 3.1 Files and the listing
A recording is a directory of these files and no other:
```
recording.wrm the recording manifest (§4)
room/descriptor.wrd the room descriptor
room/grant-<k>.wrg each room grant
room/key-log.json the head of the room authority's key log
events/<principal>.wev each principal's chain
takes/<take>/manifest.wtm a take's manifest
takes/<take>/chunk-<index>.bin each chunk of the take the package holds
takes/<take>/receipt-<k>.wur the store's upload receipts
takes/<take>/consent-<k>.wcn the consent records
composite/receipt-<digest>.wcr the composite's receipts, optionally (§3.5)
composite/… and the objects they name
LISTING
```
`LISTING` is the listing (SPEC §3.1) of every other file, and its SHA-256 is the **package
digest**, as a package of packaging §11 has its own. Every path is one a listing can carry.
`<principal>` and `<take>` are a key and a take id as 64 lowercase hexadecimal digits,
`<index>` a chunk's index as ten decimal digits, and `<k>` a position as four decimal
digits from `0000`.
### 3.2 Components
A package has these components, each the files below one directory:
| kind | component | `id` | directory |
|---|---|---|---|
| 1 | the room | `room` | `room/` |
| 2 | the events | `events` | `events/` |
| 3 | a take | the take id, 64 lowercase hexadecimal digits | `takes/<take>/` |
| 4 | the composite | `composite` | `composite/` |
A component's **`sha256`** is the SHA-256 of the listing (SPEC §3.1) of its files, each by
its path below the component's directory. That is SPEC §7.1's component identity for a
component of several files: an `id`, of SPEC §7.1's form, and the `sha256` of its bytes,
here serialised as the listing that SPEC §3.1 gives a set of several files, as a pin in the
form `wai.prior.set` covers one. No other per-component digest is defined.
### 3.3 The room and the events
The room component holds `descriptor.wrd`, the room descriptor (live-rooms §3); one
`grant-<k>.wrg` for each room grant (live-rooms §4), numbered from `0000` without a gap in
the order a room verifier is given them (live-rooms §9.1); and `key-log.json`, the head of
the room authority's key log that the room verifier is given (live-rooms §5), when the
descriptor names an origin. It holds no other file.
The events component holds one file for each principal whose chain the recording holds,
named by the principal's key: its chain whole, its records concatenated in its stream order
(live-events §8.6). A record's length follows from its own fields (live-events §3.1), so the
file splits into records with no framing; a file that does not split into records that read
(live-events §3.3), each of that principal, is not a chain. A room verifier is given the
chains in the order of their files' paths, ascending order of the principals' keys, and that
order is the chain index by which live-rooms §9.2's merged order breaks ties.
### 3.4 A take
A take component's directory is `takes/<take>/`, its `id` the take id (capture-binding
§3.2). It holds `manifest.wtm`, the take manifest (capture-binding §7); one
`chunk-<index>.bin` for each chunk of the master the package holds, the chunk's bytes; the
store's upload receipts in their order as `receipt-<k>.wur` (capture-binding §6.2); and the
consent records as `consent-<k>.wcn` (capture-binding §8.1). It holds no other file.
The chunk files are the take's **parts** (capture-binding §6.1), given to the take verifier
in the order of their indexes. A chunk the store never received has no file: the package
holds what was uploaded, and the take's `complete` verdict says whether that is every
committed chunk. The master is its chunks concatenated in index order (capture-binding
§4.1).
### 3.5 The composite
A recording **MAY** hold the room's composite: the objects of a composed track, each with
the composition receipt `WCR1` its compositor signed for it, which `wai.live.compose`
defines (live-compose §8). The component `composite` holds each receipt as
`receipt-<digest>.wcr`, `<digest>` the receipt's digest (SHA-256 over its bytes before its
signature, live-compose §8.1) in 64 lowercase hexadecimal digits, and the objects the
receipts name beside them. The recording manifest names the component by its `sha256` like
any other, so the listing binds each receipt's bytes and its name its digest. A recording verifier of this revision
checks that digest and reads nothing in the component: verifying a composition receipt is
live-compose §8.3's, a later extension, and this extension depends on nothing of it but the
name and digest of its record.
## 4. The recording manifest
### 4.1 Layout
When the session is over, the room authority, or a principal that held the record power over
the whole span (§4.3), signs a recording manifest, `WRM1`:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WRM1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `signer` | 32 | the key that signs the manifest |
| `from` | 8 | u64: the session span's first instant, room clock in microseconds |
| `to` | 8 | u64: its last instant |
| `claims` | 1 | the claims the manifest makes: bit 0 `complete` |
| `purposes` | 1 | the purposes it claims every take `publishable` for (capture-binding §2) |
| `region` | 2 | ISO 3166-1 alpha-2: where the package is kept |
| `components` | 2 + … | a u16 count, then each component: `kind` (1), the length of its `id` (1), the `id`, and its `sha256` (32) |
| `sig` | 64 | Ed25519 by `signer` |
The **digest** of a manifest is SHA-256 over its bytes before `sig`; its **signature** is
over the 19 bytes `wai:live-recording` (ASCII) and 0x01, followed by the bytes the digest
covers.
### 4.2 Reading and rules
A reader reads exactly the bytes it is given, as live-events §3.3 reads a record with
`WRM1` for `WEV1` (`truncated`, `malformed`, `unknown_version`), and refuses a manifest
that breaks any of these rules as `recording_manifest_invalid`:
- `claims` sets no bit above bit 0, and `purposes` none above bit 3; `from` is at most `to`;
`region` is two capitals `A`–`Z`;
- the first component is the room (kind 1, `id` `room`) and the second the events (kind 2,
`id` `events`); then come the takes, each of kind 3 with an `id` of 64 lowercase
hexadecimal digits, in strictly ascending order of `id`; then, last, at most one
composite (kind 4, `id` `composite`); and no other component.
It then refuses one whose signature does not verify as `bad_key` or `bad_signature`. Each
`id` the rules admit is of SPEC §7.1's form, and the order makes the manifest of one
package one sequence of bytes.
### 4.3 Who signs
The room authority (the descriptor's `authority`, live-rooms §3.1) may sign any span. Any
other signer **MUST** hold the **record** power (live-rooms §3.3) at every instant of the
span, from `from` to `to` inclusive.
A principal holds the power at room-clock instant *t* when, in the room state replayed to
*t* (live-rooms §9.3: every `join`, `grant_exchange` and `leave` of it that the room verifier
accepted with its `t` at or below *t*, in the merged order), it is present, and its grant in
effect has the record power, is not revoked (live-rooms §5), and covers *t*: `nbf` ≤
⌊(`t0` + *t*) / 10^6⌋ < `exp`. So the grant took effect, by a `join` or a `grant_exchange`,
at or before `from`, and nothing takes the power away before `to`: no `leave`, no
`grant_exchange` to a grant without it, and no expiry.
A signer that does not hold it throughout is refused `recording_signer_not_permitted`, and
the verifier states the **first instant** of the span at which it does not hold it: `from`
when it held no grant with the power there; otherwise the `t` of the `leave` or the
`grant_exchange` that took the power away, or the first instant its grant does not cover.
One code with the instant, not one code per cause: the power over the span is one property,
each way it lapses is already a record of the room (a lifecycle event, or a grant's window)
that the instant points at, and the instant is what a signer needs to know to seal a span it
did hold the power over. A span that fails several ways fails first where the instant says.
The record power is the power to start and stop a recording, and sealing the session is the
end of that act. Held only at the end, it would let a principal granted it just before the
end seal a session it never held the power to record. A recorder that gained the power
partway through signs a manifest whose span starts no earlier than its grant took effect;
the package still holds the room's chains whole (§3.3), and §4.4 step 6 holds every take it
holds to that span. A device needs no record power to commit to its take, and a device whose
grant has none cannot seal the room's recording.
A revocation in the key log is not bounded by a time (live-rooms §5): the room verifier
refuses every use of a revoked grant, so a head that revokes the signer's grant refuses the
recording at the room (§4.4 step 4), before this rule is reached.
### 4.4 Verification
A recording verifier is given a package's files by path, and an evaluation instant `at` in
seconds since 1970-01-01T00:00:00Z, which it gives each take verifier (capture-binding
§9.1). It runs these steps and stops at the first refusal, which it reports with the reason
and where:
1. **The listing.** `LISTING` is present and is the listing of every other file:
otherwise `listing_mismatch`, at `listing`.
2. **The manifest.** `recording.wrm` is present, otherwise `recording_manifest_invalid`,
and it reads (§4.2); at `manifest`.
3. **The components.** For each component of the manifest, in its order: its directory
holds a file, otherwise `component_invalid`, and the SHA-256 of its listing is its
`sha256`, otherwise `component_digest_mismatch`; at that component. Then every file is
`LISTING`, `recording.wrm`, or a file below a component's directory: otherwise
`component_unlisted`, at `listing`.
4. **The room.** The room component holds the files of §3.3 and no other, and each file of
the events component is a chain of the principal it names (§3.3): otherwise
`component_invalid`, at that component. The room verifier (live-rooms §9) is given the
descriptor, the grants, the key-log head (or none) and the chains: a refusal is the
recording's, at `room` with the room verifier's own place. A gap is not a refusal: the
steps go on, and §4.5 says what a gap fails. The manifest's `room` is the room id:
otherwise `wrong_room`, at `manifest`.
5. **The signer** (§4.3): otherwise `recording_signer_not_permitted`, at `signer` with the
first instant of the span at which it does not hold the record power. **The region**:
where the room's recording policy pins regions (live-rooms §3.7), `region` is one of
them, otherwise `store_outside_region`, capture-binding §6.3's code, at `manifest`.
6. **The takes.** For each take component, in the manifest's order: it holds the files of
§3.4 and no other, otherwise `component_invalid`; the take verifier (capture-binding §9)
is given the room as in step 4, the take manifest, the parts, the receipts, the
consents and `at`, and its refusal is the recording's, at `take` with the component's
index and the take verifier's own place; the take id it reads is the component's `id`,
otherwise `component_invalid`; and the take lies within the span, its start (the
`record_start` it answers) at or after `from` and its last held commitment at or before
`to`, otherwise `outside_span`; at that component. The records before `from` or after
`to` that the package holds are the room's chains whole, which the room verifier replays
the room from; the manifest states nothing of them.
7. **The verdicts** (§4.5).
### 4.5 Verdicts and outcome
**`complete`** holds when the recording holds every take of the session that starts within
the span, each whole, and every event the room verifier was offered: the first reason below
that applies.
1. `delivery_gap`: the room verifier holds a gap (live-rooms §9.4).
2. `take_omitted`: a take of the session whose `record_start` lies within the span has no
component. A **take of the session** is the take id (capture-binding §3.2) that a
`record_start` the room verifier accepted (live-events §4.9) names for its target, where
the room holds a `take_commit` of that target whose payload reads (capture-binding §5.2)
and names that take: a start its device answered.
3. For each take component, in the manifest's order: its `single-take` verdict fails with
`delivery_gap`, `commit_chain_broken` or `take_not_closed`, so its commitments are not
one unbroken chain from the opening commitment to the closing one: that reason; or its
`complete` verdict fails: that reason (capture-binding §9.4).
**`publishable`** for purpose *p* holds when every take is `publishable` for *p*: for the
first take component, in the manifest's order, whose `publishable` for *p* fails, that
reason.
A recording verification gives one outcome:
- **refuse**: the reason and where (§4.4);
- **verdicts**: the package digest; the manifest's digest; the room id; the verdicts of
`complete` and of `publishable` for each of the four purposes; each take's id and verdicts
(capture-binding §9.5); and whether the manifest **stands**: every claim it makes, and
`publishable` for every purpose it names, holds.
## 5. Clips
### 5.1 The clip record
A clip is a span of one take, or one window of several takes aligned on the room clock
(§6). A cutter signs a clip record, `WCL1`:
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCL1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id |
| `window_in` | 8 | u64: the first room-clock instant the clip presents, in microseconds |
| `window_out` | 8 | u64: the instant it presents up to (excluded) |
| `parts` | 1 + … | a u8 count *k*, then each part as below |
| `signer` | 32 | the cutter's key |
| `sig` | 64 | Ed25519 by `signer` |
Each part:
| field | size | meaning |
|---|---|---|
| `device` | 32 | the device's key |
| `take` | 32 | the take id |
| `head` | 32 | the digest of the `take_commit` event whose commitment the proof is against |
| `size` | 8 | u64: that commitment's size |
| `root` | 32 | that commitment's root |
| `first` | 8 | u64: the index of the first chunk the part covers |
| `count` | 4 | u32: the number of chunks it covers |
| `trim_in` | 8 | u64: the bytes of the first chunk before the clip's bytes |
| `trim_out` | 8 | u64: the bytes of the last chunk after them |
| `chunks` | 64 × `count` | the descriptor of each chunk it covers (capture-binding §4.1), in index order |
| `proof_len` | 1 | the number of nodes in `proof` |
| `proof` | 32 × `proof_len` | the range proof (§5.3) |
The **digest** of a clip record is SHA-256 over its bytes before `sig`; its **signature**
is over the 14 bytes `wai:live-clip` (ASCII) and 0x01, followed by the bytes the digest
covers. A part's bytes are its chunks' bytes concatenated, less `trim_in` bytes at the
start and `trim_out` at the end; the clip's bytes are its parts'.
### 5.2 Reading and rules
A reader reads exactly the bytes it is given, as live-events §3.3 reads a record with
`WCL1` for `WEV1` (`truncated`, `malformed`, `unknown_version`), and refuses a record that
breaks any of these rules as `clip_record_invalid`:
- *k* is 1 to 16, and no take is named by two parts;
- in each part, `count` is at least 1; each chunk's `index` is `first` plus its position,
and each `len` is at least 1; `proof_len` is at most 128; `trim_in` is below the first
chunk's `len`, and `trim_out` below the last chunk's; and, with one chunk, `trim_in` +
`trim_out` is below its `len`;
- a part with a trim that is not 0 is the clip's only part;
- `window_in` is below `window_out`.
It then refuses one whose signature does not verify as `bad_key` or `bad_signature`.
**Partial chunks.** A clip whose range begins or ends inside a chunk carries that whole
chunk and states the bytes it leaves out, as `trim_in` and `trim_out`. A leaf commits to a
chunk's digest over all its bytes (capture-binding §4.2), so a part of a chunk can never be
proved on its own: with the whole chunk the proof stays exact, and the trims say which of
the proved bytes the clip presents. Refusing a range that does not fall on chunk boundaries
would make a byte range of the master, or a range request, unprovable unless it happened to
land on them. A trim is in bytes, which have no committed time within a chunk, so a trimmed
clip's window is its chunks' span (§5.4 step 3j), and trims are a single take's: an aligned
clip (§6) presents a window of time, not of bytes. A cutter that must not disclose the bytes
outside the range cuts on chunk boundaries, since the trimmed bytes travel with the clip.
### 5.3 The range proof
A part proves its chunks by one proof over the run of leaves they are, against the head.
With `D[n]` the take tree's leaves (capture-binding §4.2) under the head's size *n*,
`MTH` RFC 9162 §2.1.1's Merkle tree hash with the take tree's node, and *k* the largest
power of two below *n*, the proof of leaves *a* to *b* − 1 (0 ≤ *a* < *b* ≤ *n*) is:
```
RANGE(a, b, D[n]) = {} a = 0 and b = n
= RANGE(a, b, D[0:k]) : MTH(D[k:n]) b ≤ k
= RANGE(a − k, b − k, D[k:n]) : MTH(D[0:k]) a ≥ k
= RANGE(a, k, D[0:k]) : RANGE(0, b − k, D[k:n]) otherwise
```
where `:` appends. A run of one leaf is never split, so `RANGE(i, i + 1, D[n])` is RFC 6962
§2.1.1's `PATH(i, D[n])`, the inclusion proof RFC 9162 §2.1.3.1 generates, node for node.
A proof of a whole tree is empty. A proof has at most 2⌈log₂ *n*⌉ nodes, at most 128 for any
size a commitment can state, and every node of it is a node of the inclusion proof of a leaf
in the run.
A verifier given the leaves *L* of the run (*b* − *a* of them), the size *n* and the
nodes, used in order from the first, computes `ROOT(a, b, n, L)`:
```
ROOT(a, b, n, L) = MTH(L) a = 0 and b = n
= node(ROOT(a, b, k, L), next) b ≤ k
= node(next, ROOT(a − k, b − k, n − k, L)) a ≥ k
= node(ROOT(a, k, k, L[0 : k − a]),
ROOT(0, b − k, n − k, L[k − a :])) otherwise
```
where `next` is the next unused node, and `node` is the take tree's (staged-measurement
Appendix C). The proof verifies when no `next` is asked for past the last node, every node
is used, and `ROOT` is the head's root. For a run of one leaf this is RFC 9162 §2.1.3.2's
check of an inclusion proof, and gives its answer.
The size is the head's, which the device signed, never the cutter's: as for an inclusion
proof, the same nodes can reach one root under two sizes (staged-measurement Appendix C),
so a size the cutter chose would let it choose the leaves' positions. Under the head's size
the recursion fixes which subtree each node stands for; the leaf and node domains differ, so
no node passes for a leaf; and each leaf binds its chunk's index, capture range, length and
digest (capture-binding §4.2). A proof that verifies therefore shows, short of a collision of
SHA-256, that the run's descriptors are the head's leaves *a* to *b* − 1.
### 5.4 Verification
A clip verifier is given the room descriptor; the clip record; and, for each part in order,
the device's key, the take's commitments (its `take_commit` events, from its opening one to
the head, in the order of the take's chain), and the bytes of each chunk the part covers,
each present or absent. It is not given the take manifest, any other chunk, the upload, or
the room's other events. It runs these steps and stops at the first refusal, which it
reports with the reason and where:
1. **The descriptor** reads (live-rooms §3.4): its refusal, at `descriptor`. The room id is
its digest, and *I* and *J* are its recording policy's (live-rooms §3.7).
2. **The record** reads (§5.2), at `clip`. Its `room` is the room id, otherwise
`wrong_room`; and the verifier is given one input for each part, otherwise
`clip_record_invalid`; at `clip`.
3. **Each part**, in order, steps (a) to (k):
- **(a)** The key given is the part's `device`: otherwise `clip_device_mismatch`, at the
part.
- **(b)** Each commitment given reads (live-events §3.3), names the room
(`wrong_room`), is the device's (`clip_device_mismatch`), verifies (`bad_key`,
`bad_signature`), is a `take_commit` whose payload reads (capture-binding §5.2,
`commit_malformed`), and names the part's `take` (`clip_take_mismatch`); at that
commitment.
- **(c)** The commitments are a chain (capture-binding §5.3): the first is an opening one,
and each later one has the `prev`, `first` and `n` that follow its predecessor, which
is not a closing one, and is itself not an opening one. A later one whose `n` is more
than one above its predecessor's is `delivery_gap`; any other break is
`commit_chain_broken`. Then each extends its predecessor (capture-binding §4.3),
otherwise `root_not_extending`. At the first commitment that fails, the chain checked
over all of them before any extension, as capture-binding §9.4 orders them. None given
is `commit_chain_broken` at the part.
- **(d)** The last commitment given is the head: its digest is `head`, and its size and
root are the part's `size` and `root`. Otherwise `tree_head_not_committed`, at the
part: a tree the take never committed proves nothing of it.
- **(e)** `first` + `count` is at most `size`: otherwise `clip_out_of_range`, at the part.
- **(f)** The proof (§5.3) shows the part's descriptors are the head's leaves `first`
onward, under `size`: otherwise `clip_not_included`, at the part.
- **(g)** Every commitment's offset is within *J* of the opening one's, *o* (capture-binding
§9.4 `single-take` 6): otherwise `clock_unstable`, at that commitment.
- **(h)** The chunks tile their capture, each `start` below its `end` and each `start` the
previous chunk's `end` (`capture_discontinuous`), and none spans more than *I*
(`chunk_too_long`) (`single-take` 7, 8); at the part.
- **(i)** With each chunk mapped by *o* (capture-binding §9.3) and *t* the room-clock
instant of the commitment that adds it (the one whose `first` ≤ `index` < `size`): no
chunk has *t* + *J* below its mapped end (`commit_early`), and then none has *t* above
its mapped end plus *I* plus *J* (`commit_late`) (`single-take` 9, 10); at the part.
- **(j)** The window: with the part's **span** the first chunk's mapped `start` to the last
one's mapped `end`, the span covers the window, the first chunk ends after
`window_in` and the last begins before `window_out`; and a trimmed part's window is
its span. Otherwise `clip_window_invalid`, at the part.
- **(k)** The bytes: for each chunk in order, its bytes are given (`clip_chunk_missing`)
and are that chunk's, their digest and length its descriptor's; bytes that are
another covered chunk's are `chunk_reordered`, any others `chunk_altered`; at that
chunk. Bytes given past the last chunk are `chunk_extra`, at the chunk past it.
Steps (b) to (k) are `single-take` over the span: the rules of capture-binding §9.4
`single-take` that hold of the chunks the clip covers, checked from the commitments up to the
head. A clip does not need rule 4 (closed), since it may be cut while the take runs (§8);
nor rule 5 (the manifest), since inclusion binds its chunks to the head instead; nor rule
9's clauses on the start and the stop, whose events it is not given. A take whose
`single-take` fails only at chunks outside a span, as by a late commitment, still gives
clips of that span that verify.
A clip that passes is **accepted**, and the verifier states: the record's digest and
signer; the window; the bound (§6.2); and for each part its take, `first`, `count`, the
take's offset *o*, its span, the window on its device's clock (§6.1), the length and SHA-256
of the bytes it presents, and its FETCH address (§8). Where the take's `single-take` holds, an
accepted clip's descriptors are its manifest's at those indexes, since rule 5 binds the
manifest's chunks to every commitment's root; the clip verifier does not need the manifest.
### 5.5 Cutting a clip
A cutter holds a take's manifest, its commitments and the chunks it covers. It picks a head:
the take's closing commitment, or, while the take runs, any commitment whose size covers the
range. It cuts:
- **by chunks**: `first` and `count`, with no trim;
- **by bytes**: a byte range [*x*, *y*) of the master, with chunk *i* at the master's bytes
from the sum of the `len` of the chunks before it: the chunks from the one holding byte
*x* to the one holding byte *y* − 1, `trim_in` *x* less the first one's offset, and
`trim_out` the last one's end less *y*;
- **by a window** [*w₀*, *w₁*) of the room clock: in each take, the chunks from the one whose
mapped capture holds *w₀* to the one whose mapped capture holds *w₁* − 1, with no trim.
A clip cut by chunks or bytes presents its chunks' span as its window. The proof is §5.3's
over the manifest's descriptors up to the head's size; the cutter signs the record.
## 6. Across takes
### 6.1 Time correspondence
An aligned clip has a part for each take it spans, over one window of the room clock. Each
take is placed on the room clock by its own mapping, the offset *o* of its opening
commitment (capture-binding §9.3): a room-clock instant *t* in the window is the device-clock
instant *t* − *o_p* in part *p*, in the chunk whose capture range holds it, at its position
in that chunk and in the master (capture-binding §10). Two parts *p* and *q* correspond at
*t*: device instant *d* in *p* is *d* + *o_p* − *o_q* in *q*. The verifier states each part's
window on its device's clock, [`window_in` − *o_p*, `window_out` − *o_p*).
### 6.2 The bound
A part's mapping is held, over every commitment up to its head, to within *J* of the offset
the device measured at that commitment (step 3g), and each chunk it covers was committed
within [−*J*, *I* + *J*] of its mapped end on the room clock (step 3i). The clip states its
**bound**, `bound_us`: *J* for a clip of one take, its mapping's to the room clock; and
2*J* for an aligned clip, between any two of its parts, each strays by *J* from the room
clock as its device measured it. A sink that presents the parts of an aligned clip together
aligns them within that bound of what the devices measured. The measurement itself, and each
commitment's `t`, are their authors' statements (live-events §11), held by the room's other
records as capture-binding §14 says.
## 7. HLS and LL-CMAF
This section is a mapping: what a standard HLS client plays of a recording, with no new
format. Where a take's master is CMAF (ISO/IEC 23000-19), cut as capture-binding §4.1 cuts
it, chunk 0 is the CMAF header and the first CMAF segment, and each later chunk one CMAF
segment, so the take's chunk files are already an HLS presentation of fragmented MPEG-4
segments (RFC 8216 §3.3; `draft-pantos-hls-rfc8216bis-22` §3.1.2):
- **One Media Playlist per take.** `EXT-X-VERSION` is 6 or higher, as `EXT-X-MAP` without
`EXT-X-I-FRAMES-ONLY` requires (RFC 8216 §4.3.2.5, §7; the draft's §4.4.4.5).
`EXT-X-TARGETDURATION` is the longest chunk's `end` − `start`, in seconds rounded up
(RFC 8216 §4.3.3.1), never above *I* by `single-take` rule 8. `EXT-X-PLAYLIST-TYPE` is
`VOD` for a closed take and `EVENT` while it runs, segments added only at the end (RFC
8216 §4.3.3.5), with `EXT-X-ENDLIST` once the take is closed.
- **The initialization section** is `EXT-X-MAP` with `URI` chunk 0's file and `BYTERANGE`
`<h>@0` (the draft's §4.4.4.5), *h* the length of chunk 0's top-level boxes (ISO/IEC
14496-12) before its first `styp` or `moof`. Chunk 0's segment is then
`EXT-X-BYTERANGE:<len − h>@<h>` of the same file (RFC 8216 §4.3.2.2), and each later
chunk its own file whole.
- **Durations and dates.** Each segment's `EXTINF` is its chunk's `end` − `start` in
seconds. The first segment carries `EXT-X-PROGRAM-DATE-TIME` (RFC 8216 §4.3.2.6), the
instant `t0` + `start` + *o* of the room clock in UTC; a gap-free take needs no other.
- **Events.** The room's events ride the playlist as live-events §8.3 maps them, as
`EXT-X-DATERANGE`, so a marker lands at its instant in the master (capture-binding §10).
- **Several takes.** Each take is its own Media Playlist. An HLS client presents one
rendition of a medium at a time; one that shows two takes together aligns them by
`EXT-X-PROGRAM-DATE-TIME`, within §6.2's bound.
- **LL-CMAF.** A CMAF segment holds CMAF chunks, each a `moof` and its `mdat`. A
low-latency playlist of a running take lists them as `EXT-X-PART` with `BYTERANGE` into
the chunk's file (the draft's §4.4.4.9), with `EXT-X-PART-INF` and
`EXT-X-SERVER-CONTROL` (§4.4.3.7, §4.4.3.8). The take tree commits to whole chunks, so a
part is proved only as part of its chunk, once the commitment that adds the chunk is
held, within *I* + *J* of the chunk's end (`single-take` 10): a sink that verifies
presents parts ahead of that and holds its verdict until the chunk is committed.
A master that is not CMAF is played this way only through a muxer for its codec, which this
extension does not define. What this revision implements of this section: nothing. The
corpus's masters are not CMAF, the reference has no muxer for a device's master (packaging
§7's CMAF writer writes a floor's AV1 fragments), and it writes no playlist.
## 8. DVR over MoQ FETCH
This section fixes only how a recording's chunks are addressed for a FETCH; how a relay
keeps a recording and answers one is a later extension's.
- **The track.** A recorded take is one track. Its Track Namespace (MOQT §2.4.1) has three
fields: the room id and the device's key, each as 64 lowercase hexadecimal digits, and
`take`; its Track Name is the take id as 64 lowercase hexadecimal digits. Every byte is one
of `a`–`z` and `0`–`9`, as in live-rooms §6.1's names, and its fields' 196 bytes are within MOQT §8.7's limit.
- **Groups and objects.** Group *g* is chunk *g*, and its one object, Object ID 0, is the
chunk's bytes whole: the unit a leaf commits to. A group's Location (MOQT §8.2) is
(*g*, 0).
- **A clip's part** is fetched by a FETCH (MOQT §3.2, §9.11) of that track whose
`LOCATION_FILTER` (MOQT §9.20.9) has StartGroup `first`, StartObject 0, EndGroupDelta
`count` − 1 and no EndObject, so every object of the end group: the range is inclusive
(MOQT §3.2), and the groups arrive in the requested order (MOQT §3.2.1). The clip
verifier states this address for each part (§5.4).
- **The commitments** are events of the device's events track (live-rooms §6.1), whose
group is the minute of room clock an event falls in (live-events §8.1). A FETCH of its
groups from the opening commitment's to the head's holds them, among the device's other
events; the clip verifier is given the take's `take_commit` events alone.
- **A running take.** The groups below the latest commitment's size are committed; a group
at or past it is a chunk no commitment has added yet, which a sink holds unverified until
one does (§5.5: a clip of a running take is proved against the commitment that covers it).
## 9. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `listing_mismatch` | §4.4 step 1 |
| `recording_manifest_invalid` | §4.2, §4.4 step 2 |
| `component_invalid` | §4.4 steps 3, 4 and 6 |
| `component_digest_mismatch` | §4.4 step 3; SPEC §7.1's code, for a component of a recording |
| `component_unlisted` | §4.4 step 3 |
| `recording_signer_not_permitted` | §4.3, §4.4 step 5, with the first instant of the span without the power |
| `outside_span` | §4.4 step 6 |
| `take_omitted` | §4.5 `complete` 2 |
| `clip_record_invalid` | §5.2, §5.4 step 2 |
| `clip_device_mismatch` | §5.4 steps 3a, 3b |
| `clip_take_mismatch` | §5.4 step 3b |
| `tree_head_not_committed` | §5.4 step 3d |
| `clip_out_of_range` | §5.4 step 3e |
| `clip_not_included` | §5.4 step 3f |
| `clip_window_invalid` | §5.4 step 3j |
| `clip_chunk_missing` | §5.4 step 3k |
`take_omitted` is a verdict; the rest are refusals. A recording verifier also raises every
code a room verifier or a take verifier raises (live-rooms §11, capture-binding §12, and the
live-events codes they list), at the room or the take; `truncated`, `malformed`,
`unknown_version`, `bad_key` and `bad_signature` for a manifest that does not read or whose
signature fails; `wrong_room` for a manifest of another room; `store_outside_region` for a
package kept outside the room's region pin; and `delivery_gap` and every
verdict of capture-binding §12 as a verdict. A clip verifier also raises the codes of a
descriptor that does not read (live-rooms §11); `truncated`, `malformed`, `unknown_version`,
`bad_key`, `bad_signature` and `wrong_room` for its record and its commitments; and
`commit_malformed`, `delivery_gap`, `commit_chain_broken`, `root_not_extending`,
`clock_unstable`, `capture_discontinuous`, `chunk_too_long`, `commit_early`, `commit_late`,
`chunk_reordered`, `chunk_altered` and `chunk_extra` at §5.4's steps, each with the meaning
capture-binding §12 gives it.
## 10. Conformance
[`live-conformance/recordings/`](../live-conformance/recordings/) holds the corpus, in one
file, `vectors.json`, written from fixed seeds by `wai_live_record_vectors gen`. Its
recordings are packages, each with its files and an evaluation instant and the outcome §4.5
gives them; its clips are each a room descriptor, a clip record and, per part, the device's
key, the commitments and the chunks' bytes, with the outcome §5.4 gives them.
- **The session.** A lesson room with two takes: the performer's, which is capture
binding's `lesson` take byte for byte (its manifest, chunks, receipts, consent and
commitments), and the accompanist's, captured beside it in chunks of another length on a
device whose clock runs ahead of the room's. The recording sealed by the room authority,
and by the director, stands, and so does one with a composite component, whose file
stands for a composed object and whose digest alone the verifier checks.
- **Recordings that fail a claim:** a take left out (`take_omitted`); capture binding's
`missing_chunk` take, whose chunk 6 was never uploaded (`chunk_missing`); a commitment
the relay did not deliver (`delivery_gap`); a take without its consent
(`consent_missing`).
- **The record power over the span:** a director that joins at 1 s seals the span from 1 s
and stands; sealing from 0 is refused at 0; and a director removed at 18 s, exchanged at
17 s to a grant without the power, or whose grant expires at 18 s, is refused at that
instant; one whose grant the key log revokes is refused at the room.
- **Recordings refused:** a file changed after sealing (`listing_mismatch`) and with the
listing rewritten (`component_digest_mismatch`), a take's or the composite's; a file of no component; a component
with a file §3 does not define; a manifest that breaks §4.2, of another room, or with a
bad signature; a manifest signed by a principal without the record power; a package kept
outside the room's region pin; a span that
ends before a take's closing commitment; a take, and a grant, whose signature fails.
- **Good clips:** a single chunk, a byte range with trims, a whole take, the first chunk,
the last chunk, a span of the second take, a span proved against a commitment made while
the take was running, a two-take clip aligned over one window, and a span of capture
binding's `late_commit` take that its late commitment does not touch.
- **Clips refused,** one for each reason of §9 a clip raises and for each `single-take`
rule of §5.4: a proof that does not reach the head, a range past the head's size, a
span over the chunk missing from capture binding's `missing_chunk` take, a clip spliced
from two takes, a reordered, altered or extra chunk, a tree head the take never
committed, a commitment left out or malformed, another take's or another device's
commitments, a record of another room, with a bad signature, cut short, or with a trim
in an aligned clip, a window its chunks do not cover, and spans of capture binding's
`late_commit`, `commit_early`, `root_not_extending`, `clock_unstable`,
`capture_discontinuous`, `chunk_too_long` and `commit_chain_broken` takes.
A conforming verifier gives every vector the outcome, the reason, the place and the data
the corpus states. `wai_live_record_vectors verify` and the test `tests/live_record.rs`
check that the file regenerates byte for byte, that `wai-rs` gives every vector its
outcome, that every reason of §9 appears in the corpus, that §9 lists exactly the
reference's codes, and that every take the corpus says it reuses is capture binding's,
byte for byte, against `live-conformance/takes/vectors.json`.
`live-conformance/live_record_verify.py` is the second implementation, written from this
text: it shares no code with `wai-rs`; it checks range proofs by §5.3's recursion and, for
a run of one chunk, by RFC 9162 §2.1.3.2's algorithm as that RFC states it; it verifies
signatures with WAI's strict rule (`claim-conformance/_ed25519.py`), each take with the
capture-binding corpus's second implementation (`capture_take_verify.py`) and the room with
the live-rooms corpus's (`live_rooms_verify.py`); it gives every vector the same outcome;
and it mutates copies of the corpus and requires every copy to fail.
## 11. Security and privacy considerations
- The recording manifest is the signer's statement of which components the session has
and of its span; it adds nothing to a take's verdicts, which the take verifier gives from
the room's events. Its signer held the record power over the whole span, so a
participant who can only publish or watch cannot seal a recording of the room, and one
granted the power late cannot seal what came before it. The package digest names the whole;
the manifest names each component, so a component can be fetched and checked alone.
- A recording is personal data of everyone it records: the masters, and the events, whose
chats, markers and captions are principals' words. The room's region pin and
`retention_days` (live-rooms §3.7) apply to the package as they apply to the takes: the
manifest states where the package is kept, and the verifier holds it to the pin; and
a take's consents bound what `publishable` admits.
- A clip proves that its bytes are those its take's device committed while it captured,
at those indexes, under a commitment the device signed. Its trust rests on the device's
key: a verifier takes it from the device's grant or the take manifest, never from the
clip. The cutter's signature binds the cutter's choice of span, window and head and
nothing more.
- A range proof's nodes are digests of digests of other chunks' descriptors; they disclose
no byte of another chunk. A trimmed clip carries its boundary chunks whole, so the bytes
it trims travel with it; a cutter that must not disclose them cuts on chunk boundaries.
- The time a clip states is the devices' measurement of the room clock, held to *J* at
every commitment; the bytes are proved exactly, the instants to within §6.2's bound.
- A clip cut while a take runs is proved against the commitment that covers it, which the
device can no longer rewrite: any later commitment must extend it (capture-binding §4.3),
so the clip stays a span of the take as it is finally committed.

View file

@ -180,8 +180,9 @@ extension's.
- `regions`: the **region pin**, for data residency: ISO 3166-1 alpha-2 codes, each two
capitals `A`–`Z`, in ascending order, at most 16. A recording of the room, and the
media and events a relay keeps of it beyond delivery, **MUST** be stored only within
those regions; none means no pin. A recording's receipt states where it is stored; the
recording extension checks it against the pin.
those regions; none means no pin. A take's upload receipts state where its master is
stored ([capture-binding](capture-binding.md) §6.3), and a recording's manifest where the
package is kept ([live-recording](live-recording.md) §4.4); each is checked against the pin.
`mode` is at most 2, and `commit_interval_ms` and `jitter_ms` keep the rule above.

View file

@ -0,0 +1,946 @@
# WAI Extension: Timed Text and Live Captions
> Status: Draft. Registers the record `wai.text.timed`: timed text, and live captions as
> events. A **cue** is a span of media time and its text, in a small, closed set of
> styling (italic runs, line breaks, and one of six anchors) that WebVTT and the IMSC1 Text
> Profile both express. A cue therefore maps onto each of them exactly, and onto the two
> MoQ caption timelines, CTA-608 and HLS. Where a target cannot hold a cue, the mapping
> refuses it and names the reason; it never drops meaning silently. A **live caption** is
> a `caption` event of [live-events](live-events.md) (§4.10 there), unchanged. A machine
> caption is revised in place until it is final: each revision is a new `caption` event,
> followed by a `meta` event whose payload is this extension's **annotation**, `WTX1`. The
> annotation names the revision it supersedes, says whether the text is final and how much
> of it is stable, and carries the anchor and the italic runs. The event's `origin` (human,
> machine or machine-reviewed), with the digest of the model that wrote it, is the
> **disclosure label**, and every mapping carries it or hands it to a sidecar. No new
> transport: field semantics and rules over them only. The reference implementation is
> the `timed_text` module of `wai-rs` (feature `timed_text`); the corpus is
> [`live-conformance/captions/`](../live-conformance/captions/), and its second
> implementation is `live-conformance/timed_text_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision, each pinned to the version named:
> WebVTT, W3C Candidate Recommendation Draft of 2026-05-20 (`CRD-webvtt1-20260520`);
> IMSC Text Profile 1.3, W3C Recommendation of 2026-05-21 (`REC-ttml-imsc1.3-20260521`);
> TTML Profiles for Internet Media Subtitles and Captions 1.0.1, W3C Recommendation of
> 2018-04-24 (`REC-ttml-imsc1.0.1-20180424`), for its profile signalling;
> IMSC Hypothetical Render Model, W3C Recommendation of 2024-04-25 (`REC-imsc-hrm-20240425`);
> `draft-wilaw-moq-webvtt-msf-00`, `draft-law-moq-imsc1-msf-00` and `draft-ietf-moq-msf-01`;
> `draft-pantos-hls-rfc8216bis-22`; the C2PA Technical Specification, version 2.4; the IPTC
> Digital Source Type vocabulary as released on 2024-10-23; EN 301 549 V4.1.1 (2026-09);
> and 47 CFR 79.101 as in force on 2021-06-01 and 47 CFR 79.102, which reproduce the 608
> and 708 decoder rules. A section number of one of those texts in this extension is that
> version's. CTA-608-E and CTA-708-E themselves could not be read for this revision: their
> publisher distributes them through an account-gated store. §8.1 and §9 say what that
> leaves unchecked.
## 1. Scope
In scope: the cue model and the text subset WebVTT and the IMSC1 Text Profile both hold
exactly (§3); live captions as `caption` events, the annotation, how revisions are ordered
and checked, what a sink shows, the cues a stream resolves to, and what a subscriber joining
mid-stream receives (§4); WebVTT documents and segments, and a reader of what this
extension writes (§5); IMSC1 Text Profile documents and a check of the hypothetical render
model (§6); the MoQ caption timelines and their catalog entries (§7); CTA-608 on field 1,
channel CC1, pop-on and roll-up (§8); a CTA-708 mapping, specified only (§9); the
disclosure label in every target (§10); and what a WAI player owes its viewers (§11).
Out of scope: a transport; speech recognition and translation, which produce the text; the
fragmented MP4 packaging of an IMSC1 rendition (HLS `draft-pantos-hls-rfc8216bis-22` §3.1.5
and ISO/IEC 14496-30 carry the document §6 writes); the paint-on style of 608, colours,
underline and flash; audio description; and a sink's own rendering of text, beyond §4.4
and §11.
Relationship to the core spec: `wai.text.timed` is a **record** ([SPEC](../SPEC.md) §5).
Its unit is the signed caption revision, and the documents this extension writes are
mappings of it, as [live-events](live-events.md) §8 maps an event onto a playlist tag. It
names no payload format and is not dispatchable. A timed-text component that the
[graphics-vector](graphics-vector.md) §5.7 package rule asks for is a WebVTT or IMSC1
document written by §5 or §6.
Relationship to [live-events](live-events.md): this extension changes nothing in it. Its
`caption` payload has no field for a revision, so the annotation rides in the `meta` kind,
which live-events leaves to applications (§4.2 there), and is bound to its caption by the
event chain (§4.3 here). A later revision of live-events MAY fold the annotation's fields
into `caption`; this extension would then read both forms.
## 2. Terms
- **Cue**: one entry of the cue model (§3.1).
- **Revision**: one `caption` event, with what its annotation says (§4.3).
- **Chain of a cue**: its revisions, the first superseding none and each later one
superseding the one before.
- **Partial**: a revision that is not final. **Final**: a revision that is.
- **Stable prefix**: the leading code points of a partial's text that no later revision of
its cue changes.
- **Document order**: cues by `start`, and in the order given among cues of equal `start`.
- **Media time**: milliseconds on the timeline of the media the cues accompany. The media
time of the room's `t0` is `pt0`, as [live-events](live-events.md) §8.4 has it.
- **Slot**: one position on the 608 field-1 pair clock (§8.2).
## 3. The cue model
### 3.1 Fields
| field | type | meaning |
|---|---|---|
| `start`, `end` | u64 | media time in milliseconds; `start` < `end` ≤ 359 999 999 (99:59:59.999) |
| `lang` | string | a BCP 47 language tag, read as [live-events](live-events.md) §4.10 reads one |
| `origin` | u8 | 1 human, 2 machine, 3 machine-reviewed: the `caption` event's `origin` |
| `model` | 32 bytes | present exactly when `origin` is 2 or 3: the `caption` event's model digest |
| `anchor` | one of six | §3.3 |
| `text` | UTF-8 | the text; its lines are separated by U+000A |
| `italic` | list of ranges | each range a first code point and a count of code points, over `text` |
`origin` 3, machine-reviewed, is text a model produced and a person reviewed, or reviewed
and corrected, before it was published. This is the reading C2PA 2.4 §18.28.3 gives the
human oversight level `human_validated` (§10.3).
### 3.2 Text: the common subset
A cue's text and italic ranges are in the subset when these rules hold. Each rule exists
because one of the targets would otherwise change what the viewer reads.
1. The text has at least one code point. Every line has at least one code point: no
U+000A at the start or the end, and no two in a row. (An empty line ends a WebVTT cue,
WebVTT §4.1.)
2. Every code point other than U+000A is in U+0020–U+007E, U+00A0–U+D7FF, U+E000–U+FFFD or
U+10000–U+10FFFF, and is not U+2028, U+2029 or U+FEFF. (These are the characters
XML 1.0 holds that are neither controls nor line or paragraph separators; a tab is
refused, since IMSC1's white-space handling turns it into a space.)
3. No line begins or ends with U+0020, and no line holds two U+0020 in a row. (The default
white-space handling of TTML collapses runs of spaces and removes spaces at a line's
edges, so an IMSC1 rendering would differ from the WebVTT one.)
4. The italic ranges are canonical: each has at least one code point and lies within the
text; they ascend, and each ends before the next begins; no range holds a U+000A; no
range begins or ends with U+0020; and between two ranges there is at least one code
point other than U+0020. (So one rendering has one spelling: two italic words with a
space between them are one range.)
### 3.3 Anchors
The caption area is the inner 80% of the picture, 10% to 90% across and down, which is the
safe caption area of 47 CFR 79.101(n)(12). An anchor puts a cue at the bottom or the top of
it, with its lines centred, at its left edge or at its right edge.
| code | anchor | WebVTT cue settings (§5.1) | IMSC1 region and `tts:textAlign` (§6.1) | 608 rows of a cue of *n* lines; column of a line of *L* cells (§8.5) |
|---|---|---|---|---|
| 0 | `bottom-center` | `line:90%,end position:50%,center size:80% align:center` | `bottom`, `center` | 16 − *n* to 15; ⌊(32 − *L*) / 2⌋ |
| 1 | `bottom-left` | `line:90%,end position:50%,center size:80% align:left` | `bottom`, `left` | 16 − *n* to 15; 0 |
| 2 | `bottom-right` | `line:90%,end position:50%,center size:80% align:right` | `bottom`, `right` | 16 − *n* to 15; 32 − *L* |
| 3 | `top-center` | `line:10%,start position:50%,center size:80% align:center` | `top`, `center` | 1 to *n*; ⌊(32 − *L*) / 2⌋ |
| 4 | `top-left` | `line:10%,start position:50%,center size:80% align:left` | `top`, `left` | 1 to *n*; 0 |
| 5 | `top-right` | `line:10%,start position:50%,center size:80% align:right` | `top`, `right` | 1 to *n*; 32 − *L* |
In WebVTT the cue box spans 10% to 90% of the width, its end edge at 90% of the height for
the bottom and its start edge at 10% for the top; in IMSC1 the regions are the same bands
of the root container (§6.1). The two place a cue in the same band with the same alignment.
They are not the same pixels, and this extension does not claim that they are.
### 3.4 Checking cues
A cue is checked in this order, and the first failure names the refusal:
1. `start` < `end` ≤ 359 999 999: otherwise `time_out_of_range`.
2. `lang` reads, and `origin` is 1, 2 or 3: otherwise `cue_malformed`.
3. A `model` when `origin` is 1: `cue_malformed`. No `model` when `origin` is 2 or 3:
`model_missing`.
4. §3.2 rules 1 to 3: otherwise `text_not_in_subset`.
5. §3.2 rule 4: otherwise `cue_malformed`.
A **document** is written for one language, `lang`, a parameter of the mapping. A mapping
whose `lang` does not read is `parameter_malformed`. It checks each cue in the order given,
then that every cue's `lang` is the document's: otherwise `language_mismatch`. A document is
one language because an HLS rendition and an MSF track each state one (`LANGUAGE`,
`draft-pantos-hls-rfc8216bis-22` §4.4.6.1; `lang`, `draft-ietf-moq-msf-01` §5.2.32).
### 3.5 What each target drops
| target | what it does not carry, and where it goes instead |
|---|---|
| WebVTT document or segment | a language (WebVTT §4.1 has none for a file): the rendition's `LANGUAGE` and the sidecar (§10.5) carry it. Partials: only final cues are written (§4.5). |
| IMSC1 document | partials, as for WebVTT. |
| MoQ WebVTT timeline | as WebVTT; the language is the catalog entry's `lang` (§7.4). |
| MoQ IMSC1 timeline, cue mode | as IMSC1. The label rides in members the draft does not define (§7.2), which a consumer may ignore; the catalog field and the sidecar carry it too. |
| CTA-608 | the label (608 has no field for it): the sidecar and the HLS characteristic carry it (§10.6). The language: the `LANGUAGE` of the `CLOSED-CAPTIONS` rendition. Times: rounded up to slots (§8.2). Position: a whole column and row. Italics: only where they begin at a row's start or after a space and end at a row's end or before one; otherwise refused (§8.5). Characters outside its sets: refused unless §8.4 substitutes one. A cue's end in roll-up: honoured only when a gap follows the cue (§8.7). |
| HLS playlist | the label of each cue: the rendition-level characteristic says that some cue is machine-generated, the in-segment form and the sidecar say which (§10.2). |
## 4. Live captions
### 4.1 A caption event is a revision
A live caption is a `caption` event ([live-events](live-events.md) §4.10): `duration_ms`,
`origin`, `model`, `lang` and `text`. Its `t` is the room-clock instant from which this
revision is shown, and `duration_ms` is how long it is shown from `t`, or 0 when not known
(§4.4). A cue revised in place is a chain of `caption` events, each followed by its
annotation. A `caption` event with no annotation is a final revision that supersedes
nothing, at anchor `bottom-center`, with no italic range. A human captioner whose captions
need neither an anchor nor italics sends `caption` events alone.
Live-events verifies every event first (§6 there): signature, chain, rates and payload. A
`caption` payload cannot express a machine caption without a model digest: live-events
reads the 32 bytes after origin 2 or 3 as the digest, and refuses a payload too short for
them, `malformed_payload`.
### 4.2 The annotation
An **annotation** is the payload of a `meta` event whose first four bytes are `WTX1`.
Integers are little-endian.
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WTX1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 32 | `supersedes` | the digest ([live-events](live-events.md) §3.2) of the `caption` event this revision supersedes; 32 zero bytes when it supersedes none |
| 37 | 1 | `state` | 0 partial, 1 final |
| 38 | 2 | `stable_cp` | u16: the number of leading code points of this revision's text that no later revision of its cue changes |
| 40 | 1 | `anchor` | the anchor's code (§3.3), 0 to 5 |
| 41 | 1 | `n` | the number of italic ranges |
| 42 | 4 × `n` | `italic` | each range: `start` u16, then `len` u16, in code points of the caption's text |
An annotation is 42 + 4 × `n` bytes. It is refused `annotation_malformed` when: it is
shorter than 42 bytes; its version is not 1; `state` is not 0 or 1; `anchor` is above 5; its
length is not 42 + 4 × `n`; `stable_cp` exceeds the code points of its caption's text; its
`state` is final and `stable_cp` is not that count; or its ranges are not canonical over its
caption's text (§3.2 rule 4).
### 4.3 Revisions, their order and their checks
A verifier of captions is offered the records of one room in stream order. It offers each
to a live-events verifier ([live-events](live-events.md) §6), which refuses it with that
extension's reason, ignores it as a duplicate, or accepts it. For each event accepted, in
stream order, of principal *P* at `seq` *s*:
1. **The caption awaiting its annotation.** The verifier keeps, for each principal, the
last `caption` event it accepted whose annotation is not yet settled. If there is one,
at `seq` *c*, and this event is an annotation at *c* + 1, it is that caption's
annotation (step 4). Otherwise the caption has no annotation: it is final, its
`stable_cp` is its text's code points, its anchor `bottom-center`, with no italic range;
and if *s* is above *c* + 1, its annotation is **unknown**, since the verifier does not
hold the event that would carry it.
2. **A caption.** Its text MUST meet §3.2 rules 1 to 3, otherwise `text_not_in_subset`. It
becomes the caption awaiting its annotation.
3. **An annotation not at** *c* + 1 **of a caption awaiting it**: when the verifier holds
*P*'s event at *s* − 1, it is `annotation_misplaced`. When it does not, the annotation is
an orphan of a delivery gap, and is read no further.
4. **An annotation of the caption at** *s* − 1, in this order:
1. it reads (§4.2, its layout): otherwise `annotation_malformed`;
2. its `t` equals its caption's: otherwise `annotation_misplaced`;
3. its `stable_cp`, its state and its ranges hold against its caption's text (§4.2):
otherwise `annotation_malformed`;
4. when `supersedes` is not zero:
- it names a `caption` event of *P* the verifier holds: otherwise, when the verifier
holds every `seq` of *P* from 0 to *s*, `supersede_unknown`; when it does not, the
reference is **unresolved**, which is not a refusal;
- the revision it names is a partial: otherwise `supersede_final`;
- no other revision supersedes it: otherwise `supersede_forked`;
- it has the same `lang` and the same anchor as this one: otherwise
`supersede_mismatch`;
- this revision's text begins with the first `stable_cp` code points of its text:
otherwise `stability_broken`.
5. Any other event changes nothing here.
At the end of the stream, a caption still awaiting its annotation has none. The outcome is
the one [live-events](live-events.md) §6.4 gives, with a refusal of this section in its
place: **refuse**, with the reason and the index of the record; **gap**; or **accept**.
Revisions are ordered by their chain: a revision supersedes exactly one partial, which no
other revision supersedes, of the same principal, so a cue's revisions form one line from
its first to its last. Since live-events holds a principal's `t` non-decreasing, a later
revision is shown no earlier than the one it supersedes. A principal MAY interleave the
revisions of several cues.
### 4.4 What a sink shows
At room-clock instant *x*, a sink shows each revision *r* it holds for which all of these
hold:
- `t`(*r*) ≤ *x*;
- no revision that supersedes *r* has `t` ≤ *x*;
- when *r*'s `duration_ms` is above 0, *x* < `t`(*r*) + `duration_ms` × 1000; when it is 0,
no later `caption` event of *r*'s principal has `t` ≤ *x*.
A sink:
- MUST replace a revision by the one that supersedes it in the same place, the anchor and
language being the same (§4.3 step 4.4);
- SHOULD NOT move or re-flow the stable prefix of a revision when its successor arrives,
and MAY set the rest of a partial's text apart, for example in a lighter weight;
- MUST show a revision whose annotation is unknown as a caption with no annotation is
shown;
- MUST show the label of every machine and machine-reviewed revision (§11).
### 4.5 The cues a stream resolves to
A stream that a verifier accepts with no gap **resolves** to cues, one per chain. For each
chain, from its first revision *r*₀ to its last revision *r*ₖ:
- *r*ₖ MUST be final: otherwise `not_final`, at the index of *r*ₖ's `caption` event;
- *r*ₖ's `duration_ms` MUST be above 0: otherwise `duration_unknown`, at that index;
- the cue: `start` = `pt0` + ⌊`t`(*r*₀) / 1000⌋; `end` = `pt0` + ⌊`t`(*r*ₖ) / 1000⌋ +
`duration_ms`(*r*ₖ), which MUST NOT exceed 359 999 999 (otherwise `time_out_of_range`);
`lang`, `origin`, `model` and `text` are *r*ₖ's caption's; `anchor` and `italic` are
*r*ₖ's;
- the cue was **published** at `pt0` + ⌊`t`(*r*ₖ) / 1000⌋ (§7.3).
The cues are in document order, cues of equal `start` in the order of their first
revisions in the stream. The first chain that fails, in that order, names the refusal. A cue
starts when its first revision was shown, so its final text is timed from when the viewer
first saw the cue. The partials are not in any document, timeline or 608 stream: none of
them can replace a cue already published (§3.5).
### 4.6 Joining mid-stream
A publisher of captions MUST add to each principal's join set ([live-events](live-events.md)
§8.2), at the start of group *g*:
- the latest revision of each cue that is shown at the instant (§4.4) and has no stated
duration: a partial whose `duration_ms` is 0, which no revision before the instant
supersedes, and which is its principal's last `caption` event before the instant;
- right after each `caption` event of the join set, its annotation, when it has one.
Live-events already requires every caption whose interval holds the instant. With these, a
subscriber that joins at group *g* verifies the join set and what follows, finds a gap
before it (§6.2 there) and refuses nothing (an annotation naming a revision it lacks is
unresolved, §4.3), and shows at every instant from the group's start what a subscriber
holding the whole stream shows.
## 5. WebVTT
### 5.1 Documents
A WebVTT document of cues in `lang` is checked (§3.4), then written as UTF-8 with U+000A
line ends, no byte order mark, as:
- `WEBVTT`, U+000A, U+000A;
- then each cue in document order, the cues separated by one U+000A, each cue:
- its timing line: `start`, ` --> `, `end`, a space, and the anchor's settings (§3.3),
each time written `HH:MM:SS.mmm` (hours always two digits, the milliseconds three);
- its payload: the lines of its text, separated by U+000A, then U+000A. In each line,
each run of italic code points (§3.2) is written between `<i>` and `</i>`, and in all
text `&`, `<` and `>` are written `&amp;`, `&lt;` and `&gt;`. A machine or
machine-reviewed cue's payload begins with the label's start tag (§10.4) and ends, after
its last line, with `</c>`.
No cue identifier, region, style block or comment is written. The `>` escape also keeps
the substring `-->`, which WebVTT §4.1 forbids in a payload, out of every payload.
### 5.2 Segments
The segments of cues in `lang`, at bounds *b*₀ < *b*₁ < … < *b*ₙ in media milliseconds (at
least two bounds, the last at most 359 999 999), with `mpegts0` the 90 kHz MPEG-TS time of
media time 0 (below 2³³), are refused `parameter_malformed` when the bounds or `mpegts0` do
not hold. Segment *k*, for media time *b*ₖ to *b*ₖ₊₁, is:
- `WEBVTT`, U+000A;
- `X-TIMESTAMP-MAP=LOCAL:`, *b*ₖ as `HH:MM:SS.mmm`, `,MPEGTS:`, (`mpegts0` + 90 × *b*ₖ)
mod 2³³ in decimal, U+000A, U+000A;
- every cue whose `start` < *b*ₖ₊₁ and whose `end` > *b*ₖ, in document order and with its
whole interval, written and separated as in §5.1.
This follows `draft-pantos-hls-rfc8216bis-22` §3.1.4: a segment holds every cue shown during
its period, with each cue's total display time, and its `X-TIMESTAMP-MAP` maps the segment's
first cue time to the 90 kHz media time, re-anchored in every segment so that a wrap of the
33-bit time stays exact. A live publisher writes a segment from the cues final when it cuts
it; a cue that becomes final later is in the later segments it overlaps, and a publisher
SHOULD delay cutting a segment by the time its captions take to become final.
### 5.3 Reading the subset
A reader of WebVTT reads only what §5.1 and §5.2 write, given the document's `lang`. It
refuses as `vtt_malformed` anything else: another header; an `X-TIMESTAMP-MAP` not as §5.2
writes it; a timing line whose times are not `HH:MM:SS.mmm` (minutes and seconds below 60)
or whose settings are not one anchor's; an empty payload line; a blank line other than one
between cues; a character reference other than `&amp;`, `&lt;` and `&gt;`; a tag other
than the label (§10.4), `<i>` and `</i>`; an `<i>` span that is empty, nested or holds a
line end; two runs of one style next to each other; and starts that go back. A label with no
model class is `model_missing`. Each cue it reads is then checked (§3.4). A WebVTT document
or segment this extension writes reads back to its cues in document order.
## 6. IMSC1 Text Profile
### 6.1 Documents
An IMSC1 document of cues in `lang` is checked (§3.4) and checked against the hypothetical
render model (§6.2), then written as UTF-8 with U+000A line ends:
```
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="LANG" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
P
</div>
</body>
</tt>
```
with `LANG` the document's `lang`, and `P` one line per cue in document order (no line when
there is no cue):
- `<p begin="START" end="END" region="R" tts:textAlign="A"`, the times as `HH:MM:SS.mmm`,
`R` and `A` the anchor's (§3.3);
- for a machine or machine-reviewed cue, ` wai:origin="O" wai:model="M"` (§10.4);
- `>`, the lines of the text separated by `<br/>`, each italic run written between
`<span tts:fontStyle="italic">` and `</span>`, `&`, `<` and `>` written `&amp;`, `&lt;`
and `&gt;`, then `</p>`.
The document uses features the Text Profile of every IMSC edition from 1.0.1 to 1.3
permits: media timing in clock time, two regions given by `tts:origin` and `tts:extent` in
percent (IMSC 1.3 §8.1.19), `tts:displayAlign`, `tts:textAlign`, `tts:fontStyle`, `span`
and `br`. Its regions lie within the root container and do not overlap (IMSC 1.3
§8.13.2), and at most two are presented at once (§8.13.3).
It signals its profile in two ways. The `ttp:profile` attribute on `tt`, equal to the IMSC
1.0 Text Profile designator, is what IMSC 1.0.1 §6.9 says SHOULD be present in a document
that is not also an EBU-TT-D or SDP-US document; that edition is the IMSC1 that
`draft-pantos-hls-rfc8216bis-22` §3.1.5 names, and IMSC 1.3's feature table permits
`#profile`. The two `ebuttm:conformsToStandard` elements, the IMSC 1.0 and the IMSC 1.3
Text Profile designators, are the form IMSC 1.3 §8.10.2 specifies, which its profile
resolution reads to set the override content profile (§6.3.2). Neither names an Image
Profile, since IMSC 1.0.1 §6.9 forbids signalling both the Image and the Text Profile in one
document; no `ttp:profile` element is written. `ttp:contentProfiles` is not written: IMSC
1.3 §8.10.1 does not recommend it in a document meant for profiles based on TTML1, such as
IMSC 1.0.1. Attributes in this
extension's namespace are foreign attributes, which IMSC 1.3 §8.6 permits and a processor
prunes. Default cell resolution (32 by 15) and font size (one cell) apply.
### 6.2 The hypothetical render model
IMSC 1.3 §8.2 says a document SHOULD conform to the IMSC-HRM. A writer of this extension
MUST check that it does, as follows, and refuses `hrm_exceeded` when the check fails. The
check is sound: a document that passes conforms to the model. It may refuse a document that
conforms, since it takes the least favourable factor of each of the model's tables.
- The intermediate synchronic documents are the spans between consecutive distinct times
among every cue's `start` and `end`, each holding the cues with `start` ≤ its beginning <
`end`. One with no cue is empty.
- No region has a background, so painting a non-empty document clears the root container
and paints no region: 1 / `BDraw` = 1/12 s (IMSC-HRM §7, §8).
- Every glyph's normalised rendered area is (1/15)², the default font size of one cell of
fifteen (IMSC-HRM §9). A glyph is a code point with its italic flag; `br` is not one.
Rendering a glyph takes (1/15)² / 0.6 = 1/135 s and copying one (1/15)² / 3 = 1/675 s:
the slowest `Ren` and `GCpy` the model's tables give, whatever the script.
- For each non-empty document in order, the code points of the `top` region's cues, then of
the `bottom` region's, each region's cues in document order: a glyph in the glyph cache
is copied and flagged; any other is rendered, added to the cache and flagged. More than
225 flagged glyphs at any point (a normalised size above 1) fails the check.
- The time it has is the time since the previous non-empty document's presentation, when
that is less than one second, and one second otherwise (the initial painting delay). The
check fails when 1/12 s plus the time of every glyph exceeds it. In units of 1/2700 s:
225 + 20 per glyph rendered + 4 per glyph copied, against 2.7 per millisecond.
- At the document's presentation, glyphs not flagged leave the cache, and the rest lose
their flag. An empty document empties the cache, which can only lengthen a later
painting.
### 6.3 The structure a verifier checks
An IMSC1 document written here is well-formed XML 1.0 in UTF-8, and parsed it has: a `tt`
root in the TTML namespace with `xml:lang` and `ttp:profile` equal to the IMSC 1.0 Text
Profile designator, no `ttp:profile` element, and no Image Profile designator anywhere; a
`head` whose metadata names both designators of §6.1; a `layout` with exactly the two
regions of §6.1, each with an `xml:id`, `tts:origin`
and `tts:extent` in percent, no `tts:position`, within the root container and not
overlapping; a `body` with one `div` of `p` elements, each with `begin` < `end` in clock
time and a `region` that names one of them; in a `p`, only text, `span` with
`tts:fontStyle="italic"`, and `br`; no attribute in a TT namespace but those §6.1 writes;
and the `wai:` attributes of §10.4 exactly on the `p` of a machine or machine-reviewed cue.
## 7. The MoQ caption timelines
The two caption timelines are MSF event timelines (`draft-ietf-moq-msf-01` §8): each MOQT
object is a JSON array of records, and each record an index and a `data` object. This
extension writes JSON with no whitespace, its members in the order given, strings with `"`
and `\` escaped, U+000A as `\n`, any other code point below U+0020 as `\u00xx`, and every
other code point as itself. `draft-wilaw-moq-webvtt-msf-00` cites MSF section numbers that
are not those of `draft-ietf-moq-msf-01` (its event timeline is MSF §8, not §11); this
extension cites MSF -01's own.
### 7.1 The WebVTT timeline
A cue's record (`draft-wilaw-moq-webvtt-msf-00` §3, §3.1):
```
{"m":START,"data":{"start":START,"end":END,"text":TEXT,"settings":{"line":LINE,"position":"50%,center","size":"80%","align":ALIGN}}}
```
`START` and `END` are the cue's media times, `TEXT` its WebVTT payload (§5.1) without the
final U+000A, `LINE` `"90%,end"` for a bottom anchor and `"10%,start"` for a top one, and
`ALIGN` the anchor's alignment. No initialization record is written, since no region or
style is used (§4 there).
### 7.2 The IMSC1 timeline, cue mode
The initialization record (`draft-law-moq-imsc1-msf-00` §4.1):
```
{"m":0,"data":{"init":{"profile":"http://www.w3.org/ns/ttml/profile/imsc1/text","styles":{"center":{"textAlign":"center"},"left":{"textAlign":"left"},"right":{"textAlign":"right"}},"regions":{"bottom":{"origin":"10% 55%","extent":"80% 35%","displayAlign":"after"},"top":{"origin":"10% 10%","extent":"80% 35%","displayAlign":"before"}}}}}
```
A cue's record (§4.2 there):
```
{"m":START,"data":{"start":START,"end":END,"region":R,"style":A,"content":[...]}}
```
`R` is the anchor's region and `A` its alignment, as JSON strings. `content` holds, line by
line, each run as `{"text":T}` or, italic, `{"text":T,"fontStyle":"italic"}`, and
`{"br":true}` between lines. A machine or machine-reviewed cue's `data` ends with
`"wai_origin":O,"wai_model":M` (§10.4). The draft defines no such member; a consumer that
ignores members it does not know loses the label there, and §10.6 applies.
### 7.3 Groups and the repeat at a group's start
Each cue is placed by the media time it was published at: its `start` for cues given as a
list, and for a stream the time of §4.5. Group *g* of a timeline covers media time
*g* × `group_ms` to (*g* + 1) × `group_ms`, `group_ms` a parameter above 0. The mapping of
groups *g*₁ to *g*₂ (*g*₁ ≤ *g*₂; otherwise, or when a cue is published before its
`start`, `parameter_malformed`) writes, for each group:
- object 0: the IMSC1 initialization record (IMSC1 timeline only), then the record of every
cue published before the group's start whose `end` is after it;
- then one object per cue published within the group, of its one record.
Cues are taken in the order they were published, by media time, in the order given among
equal times. This is the first object of each group repeating "all accumulated cue
records up to that point" (`draft-wilaw-moq-webvtt-msf-00` §5) and the independence of
`draft-ietf-moq-msf-01` §8.3, read as [live-events](live-events.md) §8.1 reads it: the
records accessible at a group's start are those of cues still to be shown, and earlier ones
stay reachable by FETCH of earlier groups. A subscriber joining at any group holds the record
of every cue that has not ended.
### 7.4 Catalog entries
A caption timeline's MSF catalog track object:
```
{"name":NAME,"packaging":"eventtimeline","eventType":"urn:msf:captions:webvtt","mimeType":"application/json","depends":[...],"role":"caption","lang":LANG,"wai_machine_generated":B}
{"name":NAME,"packaging":"eventtimeline","eventType":"urn:msf:captions:imsc1","mimeType":"application/json","depends":[...],"role":"caption","lang":LANG,"imsc1Mode":"cue","wai_machine_generated":B}
```
`B` is `true` when any cue of the track is machine or machine-reviewed, and `false`
otherwise. `wai_machine_generated` is a custom field, which `draft-ietf-moq-msf-01` §5 lets
a producer add and requires a parser to ignore when it does not understand it.
## 8. CTA-608
### 8.1 Sources
The basic character set, the special characters, the mid-row codes, the miscellaneous
control codes, the preamble address codes, parity and the redundant sending of control
codes are as 47 CFR 79.101 reproduces CTA-608's decoder rules (paragraphs (e) to (j)), read
as in force on 2021-06-01. The extended characters (first byte 0x12 or 0x13) are not in that
regulation. This revision's table of them (§8.4) is the one the 608 standard's extended
character set is commonly given as, and it has not been checked against CTA-608-E: a reviewer
with the standard SHOULD check it. The one code whose character this revision could not
settle, 0x12 0x29, is left unassigned: the encoder never sends it, and the decoder refuses
it.
### 8.2 The slot clock and the frame rate
The encoder writes field 1, data channel 1 (CC1) only. It writes on a **slot clock**: one
byte pair per slot, at `num` / `den` slots a second, a parameter. Slot *k* is at media
time *k* × 1000 × `den` / `num` ms, and the slot of media time *t* ms is the first at or
after it, ⌈*t* × `num` / (1000 × `den`)⌉. The rate MUST be a positive `num` and `den` of at
most 1 000 000 each, otherwise `parameter_malformed`.
The stream depends on the rate in its timing and its capacity: every slot carries one pair,
so a faster clock carries more bytes a second, and each cue's display starts at a slot of
that clock. On line 21, and in 608 data carried with video of 30000/1001 frames a second,
the rate is 30000/1001. A carrier that gives field-1 pairs at another rate states it, and
the same cues then give the same pairs in other slots, or are refused `too_dense` where the
pairs no longer fit. How a carrier places slots in video frames (`cc_data` of CTA-708, for
example) is the carrier's and is not specified here.
### 8.3 Bytes
- Every byte has odd parity: bit 7 is set exactly when the other seven bits have an even
number of ones.
- A **code** is either one byte of the basic set or a two-byte code whose first byte is
0x10 to 0x1F. Standard bytes are packed two to a pair, in order. A lone standard byte
before a two-byte code, or at the end of a cue's codes, is paired with 0x00.
- Every two-byte code, control code or character alike, is sent twice, in consecutive
slots: a decoder ignores a pair that repeats, in the next slot, a two-byte pair it acted
on (47 CFR 79.101(i)(4)). So two equal two-byte characters in a row take four pairs.
- A slot that carries nothing carries 0x80 0x80. The **hexadecimal form** of a stream lists
only the slots that carry a pair, one per line in ascending order: the slot in decimal (no
leading zero), a space, the pair as four lowercase hexadecimal digits, then U+000A.
### 8.4 Characters
Each code point of a cue's text, after substitution, is sent as the first that applies:
- **Substitution.** U+2019 is sent as U+0027; U+2013, U+2010 and U+2011 as U+002D.
- **The basic set**, one byte: 0x20 to 0x7A as the ASCII character of that value, except
0x2A `á`, 0x5C `é`, 0x5E `í`, 0x5F `ó` and 0x60 `ú`; and 0x7B `ç`, 0x7C `÷`, 0x7D `Ñ`,
0x7E `ñ`. So ASCII `*`, `\`, `^`, `_`, `` ` ``, `{`, `|`, `}` and `~` are not basic. 0x7F
(the solid block) is not sent.
- **A special character**, 0x11 then:
| 0x30 | 0x31 | 0x32 | 0x33 | 0x34 | 0x35 | 0x36 | 0x37 | 0x38 | 0x39 | 0x3A | 0x3B | 0x3C | 0x3D | 0x3E | 0x3F |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ® | ° | ½ | ¿ | ™ | ¢ | £ | ♪ | à | U+00A0 | è | â | ê | î | ô | û |
0x39 is the transparent space, written U+00A0 in a cue.
- **An extended character**, 0x12 or 0x13 then the code below, sent after a standard
**fallback** character, which the extended code backspaces over:
| second byte | 0x20 | 0x21 | 0x22 | 0x23 | 0x24 | 0x25 | 0x26 | 0x27 | 0x28 | 0x29 | 0x2A | 0x2B | 0x2C | 0x2D | 0x2E | 0x2F |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0x12 | Á | É | Ó | Ú | Ü | ü | ‘ | ¡ | * | (unassigned) | — | © | ℠ | • | “ | ” |
| 0x13 | Ã | ã | Í | Ì | ì | Ò | ò | Õ | õ | { | } | \ | ^ | _ | \| | ~ |
| second byte | 0x30 | 0x31 | 0x32 | 0x33 | 0x34 | 0x35 | 0x36 | 0x37 | 0x38 | 0x39 | 0x3A | 0x3B | 0x3C | 0x3D | 0x3E | 0x3F |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0x12 | À | Â | Ç | È | Ê | Ë | ë | Î | Ï | ï | Ô | Ù | ù | Û | « | » |
| 0x13 | Ä | ä | Ö | ö | ß | ¥ | ¤ | ¦ | Å | å | Ø | ø | ┌ | ┐ | └ | ┘ |
The fallback of an accented capital is its unaccented capital (`Á` `À` `Â` `Ã` `Ä` `Å`
→ `A`; `É` `È` `Ê` `Ë` → `E`; `Í` `Ì` `Î` `Ï` → `I`; `Ó` `Ò` `Ô` `Õ` `Ö` `Ø` → `O`;
`Ú` `Ù` `Û` `Ü` → `U`; `Ç` → `C`), of an accented small letter its unaccented small
letter (`ã` `ä` `å` → `a`; `ë` → `e`; `ì` `ï` → `i`; `ò` `õ` `ö` `ø` → `o`; `ù` `ü` →
`u`; `ß` → `s`); of `“` `”` `«` `»` it is `"`, of `‘` it is `'`, of `¡` it is `!`, and of
every other extended character a space.
A code point none of these sends is `char_not_in_608`. A cue's text after substitution is
what a decoder shows.
### 8.5 Rows, columns and italics
A cue's lines are laid out, in this order of checks:
1. A pop-on cue has at most 4 lines, a roll-up cue at most as many as its window has rows:
otherwise `too_many_rows`.
2. Every code point is sent (§8.4): otherwise `char_not_in_608`.
3. Each line is at most 32 cells, one cell per code point: otherwise `row_too_long`.
4. Every italic run of a line begins at the line's start or right after a U+0020, and ends
at the line's end or right before a U+0020: otherwise `style_not_expressible`. A mid-row
code is shown as a space (47 CFR 79.101(h)(1)(i)), so italics can change only where a
space already stands.
The rows and columns are §3.3's; in roll-up every line is written on the base row (§8.7).
A row is written as:
- **Position.** When the line begins italic at column 0: the italic preamble address code
of its row (second byte 0x4E or 0x6E). When it begins italic at column *c* ≥ 1: the
cursor is placed at *c* − 1, then the italics mid-row code (0x11 0x2E), which takes that
cell as a space. Otherwise the cursor is placed at *c*. The cursor is placed at a column
*x* by the preamble address code of the row with indent 4 × ⌊*x* / 4⌋ (second byte 0x50 +
2 × ⌊*x* / 4⌋ above the row's base), then, when *x* mod 4 is not 0, tab offset *x* mod 4
(0x17 0x21 to 0x23).
- **Characters**, in order. A U+0020 just before an italic run is sent as the italics
mid-row code; a U+0020 just after one, as the white mid-row code (0x11 0x20), which ends
italics. Every other code point is sent as §8.4 says.
The first byte of a preamble address code for rows 1 to 15 is 0x11, 0x11, 0x12, 0x12, 0x15,
0x15, 0x16, 0x16, 0x17, 0x17, 0x10, 0x13, 0x13, 0x14, 0x14; the base of its second byte is
0x60 for rows 2, 4, 6, 8, 10, 13 and 15, and 0x40 for the others.
### 8.6 Pop-on
The cues are checked (§3.4, §8.5) in document order, then: two cues whose intervals overlap
(a cue starting before the one before it ends) are `cues_overlap`, since pop-on shows one
caption at a time. With *s*ᵢ and *e*ᵢ the slots of cue *i*'s `start` and `end`:
- *e*ᵢ MUST be at least *s*ᵢ + 2: otherwise `too_dense`.
- **End of Caption** (0x14 0x2F), sent twice, takes slots *s*ᵢ and *s*ᵢ + 1: the cue is
shown from the first.
- **Erase Displayed Memory** (0x14 0x2C), sent twice, takes slots *e*ᵢ and *e*ᵢ + 1 when cue
*i* is the last or *s*ᵢ₊₁ ≥ *e*ᵢ + 2. Otherwise the next caption replaces this one at
*s*ᵢ₊₁.
- **The load** of cue *i*: Resume Caption Loading (0x14 0x20), Erase Non-displayed Memory
(0x14 0x2E), then its rows top to bottom (§8.5), packed (§8.3). Its units, a pair or a
doubled code (two consecutive slots), are placed last first, each in the latest free
slots before the unit after it (before *s*ᵢ for the last), and none before slot
*s*ᵢ₋₁ + 2 (slot 0 for the first cue). Slots taken by an erase are not free. A load that
does not fit is `too_dense`.
### 8.7 Roll-up
With a window of *D* rows (2, 3 or 4), the cues are checked as for pop-on, and cues whose
anchors are not all bottom or all top are `style_not_expressible`: the window would move
with the lines in it. The base row is 15 for the bottom and *D* for the top. Each cue's
codes are: the roll-up command of *D* (0x14 0x25 to 0x27), for the first cue only; then for
each line, a Carriage Return (0x14 0x2D) when the base row holds a line (any line after the
first since the start or since an erase), then the row (§8.5). The codes, packed, take
consecutive slots from *s*ᵢ, which MUST NOT be before the slot after the previous cue's last
(its last pair, or its erase); they MUST end before *e*ᵢ when the cue is erased, and before
*s*ᵢ₊₁ when it is not. Otherwise `too_dense`. The erase is sent as in pop-on, at *e*ᵢ, when
cue *i* is the last or *s*ᵢ₊₁ ≥ *e*ᵢ + 2. Text is shown as it arrives, and a cue followed
directly by the next stays on screen above it until it rolls off.
### 8.8 Decoding
The decoder reads the hexadecimal form (§8.3) and refuses as `cc_malformed`: a line not in
that form, slots that do not ascend, a byte with even parity, a two-byte code the encoder
does not send, a standard byte outside the basic set, an extended code left unassigned
(§8.4), and a character before any mode is set. It keeps the displayed and the non-displayed
memory of 15 rows of 32 cells, the cursor, the italic attribute and the base row, and acts
on each pair as 47 CFR 79.101(f) to (i) say, ignoring a two-byte pair that repeats in the
next slot the one it acted on. It reports:
- at each End of Caption, `show` with its slot and the displayed rows;
- in roll-up, `line` with the slot of the line's preamble address code and the base row,
when a Carriage Return, an erase or the end of the stream commits it;
- at each Erase Displayed Memory, `erase` with its slot.
A row is read from its first to its last cell holding a character that is not a mid-row
code; cells of mid-row codes within it read as spaces. A code point is italic when its cell
is, and a space is in an italic range when the cells on both sides are italic characters.
What the decoder reports of an encoded stream is, for pop-on, each cue shown at *s*ᵢ with
its rows and erased at *e*ᵢ as §8.6 says; and for roll-up, each line of each cue in order,
from a slot of its cue, with the erases of §8.7.
## 9. CTA-708 (specified only)
This revision specifies the mapping of cues onto CTA-708 caption service blocks and does not
implement it. CTA-708-E could not be read for it, so this section gives the semantics of
each step and no byte values; an implementation MUST take the command codes, the window and
pen attribute layouts and the caption channel packet rules from CTA-708-E itself, and its
corpus would be the check of this section.
- **Service.** Captions in the document's language are caption service 1, the primary
service (47 CFR 79.102(c)); a second language is service 2. In HLS the rendition is a
`CLOSED-CAPTIONS` tag with `INSTREAM-ID="SERVICE1"` (`draft-pantos-hls-rfc8216bis-22`
§4.4.6.1), and in an MSF catalog an accessibility descriptor of scheme
`urn:scte:dash:cc:cea-708:2015` (`draft-ietf-moq-msf-01` §5.2.44).
- **Windows.** One window per anchor in use, its window id the anchor's code (§3.3), defined
with relative positioning: anchor point bottom-left, bottom-centre or bottom-right at 90%
of the height for a bottom anchor, top-left, top-centre or top-right at 10% for a top
one, at 10%, 50% or 90% of the width for left, centre and right; rows and columns locked;
at most 4 rows (47 CFR 79.102(f)(1)) and 32 columns, so that a 4:3 receiver shows it
(79.102(e)(3)).
- **Pop-on.** A cue's window is defined hidden and cleared, its lines written (a carriage
return between lines), and then displayed at the cue's start and hidden or deleted at its
end, the display and hide commands taking the place of 608's End of Caption and erase.
- **Roll-up.** One visible window of *D* rows at the base anchor, lines written with a
carriage return before each, as in §8.7.
- **Italics.** Set by the pen attributes before a run and cleared after it, at any
position: 708's pen attributes take no cell, so §8.5 rule 4 does not apply.
- **Characters.** The G0, G1 and G2 sets as 47 CFR 79.102(d) requires decoders to support
them, with its substitutions where a decoder lacks a G2 character; a code point in none of
them is refused, as §8.4 refuses one that 608 cannot send.
- **The label.** 708 has no field for it: §10.6 applies, as for 608.
## 10. The disclosure label
### 10.1 Its source
The label of a cue is its `origin` and, for origins 2 and 3, its model digest, as the
`caption` event carries them ([live-events](live-events.md) §4.10). A cue is
**machine-generated** when its origin is 2 or 3. Every mapping of §5 to §8 carries the
label, in band where the target can (§10.2 to §10.4) and in the sidecar where it cannot
(§10.5, §10.6). A machine caption with no model digest is refused wherever it is met:
`malformed_payload` in an event (§4.1), `model_missing` in a cue (§3.4) and in a WebVTT
label (§5.3).
### 10.2 HLS
`draft-pantos-hls-rfc8216bis-22` §4.4.6.1 defines, in `CHARACTERISTICS`: "Any Rendition MAY
include the "public.machine-generated" characteristic to indicate the Rendition was
authored or translated programmatically." This extension uses that characteristic as
the draft defines it; it is in the IETF draft itself, so no other document is needed for
it. The `EXT-X-MEDIA` tag of a caption rendition is written, attributes in this order:
- a WebVTT or IMSC1 rendition: `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="G",NAME="N",LANGUAGE="L",CHARACTERISTICS="C",URI="U"`;
- 608 on CC1: `#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="G",NAME="N",LANGUAGE="L",INSTREAM-ID="CC1",CHARACTERISTICS="C"`.
`C` is the accessibility characteristics the producer states (for captions,
`public.accessibility.transcribes-spoken-dialog` and
`public.accessibility.describes-music-and-sound`), in its order, then
`public.machine-generated` when any cue of the rendition is machine-generated, joined by
commas; `CHARACTERISTICS` is left out when `C` is empty. A rendition with a machine-generated
cue MUST carry the characteristic and one without MUST NOT. `G`, `N`, `U` and each stated
characteristic are one or more of the ASCII letters, digits, space, `.`, `_`, `-`, `/` and
`:`; a stated characteristic holds no comma and is not `public.machine-generated` itself;
otherwise `parameter_malformed`. A `CLOSED-CAPTIONS` tag names a channel within the video
renditions rather than a rendition of its own (§4.4.6.1 there), and this extension carries
the characteristic there too.
### 10.3 C2PA and IPTC
For a document or rendition, its cues give:
- an IPTC digital source type (the vocabulary `http://cv.iptc.org/newscodes/digitalsourcetype/`):
`digitalCreation` when every cue is human (or there is none), `trainedAlgorithmicMedia`
when every cue is machine, `compositeWithTrainedAlgorithmicMedia` when every cue is
machine-reviewed, and `compositeSynthetic` when the cues have more than one origin. It is
the `digitalSourceType` of the `c2pa.created` action of the rendition's C2PA manifest
(C2PA 2.4 §18.15.4.5).
- one `c2pa.ai-disclosure` assertion (C2PA 2.4 §18.28) per model, in the order models first
appear in document order, labelled `c2pa.ai-disclosure`, `c2pa.ai-disclosure__1`, … (C2PA
2.4 §6.4), each with `modelType` `c2pa.types.model` (the generic model type of Table 12
there), `modelIdentifier` `tag:transaction.science,2026:wai/model/` and the digest in
lowercase hexadecimal, and `contentProfile.humanOversightLevel` `fully_autonomous` when any
cue of that model is machine, and `human_validated` otherwise. This pairing of digital
source type and oversight level is C2PA 2.4 §18.28.3's.
The statement is written as the JSON
`{"digitalSourceType":DST,"assertions":[{"label":L,"data":{"modelType":"c2pa.types.model","modelIdentifier":ID,"contentProfile":{"humanOversightLevel":H}}},…]}`.
Embedding it in a manifest, and signing that, is C2PA's and [provenance](provenance.md)'s.
### 10.4 In WebVTT and IMSC1
- **WebVTT.** A machine or machine-reviewed cue's payload is one cue class span (WebVTT
§4.2.2) whose classes are `wai-machine` or `wai-machine-reviewed`, then `wai-model-` and the
digest in 64 lowercase hexadecimal digits: `<c.wai-machine.wai-model-…>…</c>`. A WebVTT
parser keeps the classes on the cue's nodes, where a style sheet (`::cue(.wai-machine)`) or
a script reads them, and `draft-wilaw-moq-webvtt-msf-00` §3 keeps the tags in its `text`.
- **IMSC1.** The cue's `p` has `wai:origin` (`machine` or `machine-reviewed`) and
`wai:model` (the digest in lowercase hexadecimal), in the namespace
`tag:transaction.science,2026:wai/timed-text`.
- **IMSC1 timeline, cue mode.** `wai_origin` and `wai_model` in the cue entry's `data`
(§7.2).
A human cue carries none of these.
### 10.5 The sidecar
The **sidecar** of a document or rendition lists every cue, in document order, with its
label:
```
{"wai_timed_text":1,"lang":LANG,"cues":[{"start":S,"end":E,"origin":O},{"start":S,"end":E,"origin":O,"model":M},…]}
```
`O` is `human`, `machine` or `machine-reviewed`, and `model` is present for the last two.
In HLS the sidecar is published with the multivariant playlist by
`#EXT-X-SESSION-DATA:DATA-ID="science.transaction.wai.timed-text",URI="U",LANGUAGE="L"`
(`draft-pantos-hls-rfc8216bis-22` §4.4.6.4; JSON is the default format), one per caption
rendition; in a package it is a component beside the rendition it describes.
### 10.6 A target that cannot carry it
A target that cannot carry the label in band (608, 708, and an IMSC1 cue-mode consumer that
ignores unknown members) MUST be published with the sidecar, and with the rendition-level
marks it can carry: the HLS characteristic (§10.2), the catalog field (§7.4) and the C2PA
statement (§10.3). A mapping MUST NOT publish machine-generated captions in a target that
carries no label while publishing no sidecar for them. A converter that reads a WAI caption
target and writes another MUST carry the label into the new one.
## 11. Obligations of a WAI player
These are requirements on a WAI player that shows captions. Each cites the provision it
answers by identifier; they are written as engineering requirements, and meeting them is not
a statement about any law.
1. **Captions can be shown.** A player of video with synchronised audio MUST have a mode
that shows the captions available, and MUST let the viewer choose to show closed captions
(EN 301 549 V4.1.1 clause 7.1.1; WCAG 2.2 Success Criteria 1.2.2 and 1.2.4, which ask
content to provide captions, prerecorded and live).
2. **In time.** A player MUST show a cue within 100 ms of its time (EN 301 549 V4.1.1 clause
7.1.2), and SHOULD within 20 ms.
3. **Preserved.** A player or converter that transmits, converts or records captions MUST
keep their position and style (EN 301 549 V4.1.1 clause 7.1.3): this extension's mappings
keep the anchor and italics, or refuse (§3.5, §8.5).
4. **Under the viewer's control.** A player that controls how captions look MUST let the
viewer change their characteristics: at least size, colours and background (EN 301 549
V4.1.1 clause 7.1.4). A viewer's settings override the anchors and italics of this
extension where they conflict, as [graphics-vector](graphics-vector.md) §5.7 has them
override drawn captions.
5. **Discoverable.** A player whose purpose is video MUST offer turning captions on and off as
a single operation, at the same level as the volume control (EN 301 549 V4.1.1 clause
7.3).
6. **Persistent.** A player MUST keep the viewer's choice, captions on or off, their language
and their characteristics, from one item and one session to the next, until the viewer
changes it (EN 301 549 V4.1.1 clause 7.3, NOTE 3).
7. **Labelled.** A player MUST show, with every machine or machine-reviewed caption, that it
is machine-generated, in a way the viewer can perceive without turning on another
feature, and SHOULD let the viewer see the model that produced it. A player MUST NOT
remove the label when the viewer's style settings apply.
## 12. Reasons
| reason | raised at |
|---|---|
| `time_out_of_range` | §3.4, §4.5 |
| `cue_malformed` | §3.4, §5.3 |
| `model_missing` | §3.4, §5.3, §10.1 |
| `text_not_in_subset` | §3.4, §4.3 |
| `language_mismatch` | §3.4 |
| `annotation_malformed` | §4.2, §4.3 |
| `annotation_misplaced` | §4.3 |
| `supersede_unknown` | §4.3 |
| `supersede_final` | §4.3 |
| `supersede_forked` | §4.3 |
| `supersede_mismatch` | §4.3 |
| `stability_broken` | §4.3 |
| `not_final` | §4.5 |
| `duration_unknown` | §4.5 |
| `vtt_malformed` | §5.3 |
| `hrm_exceeded` | §6.2 |
| `parameter_malformed` | §3.4, §5.2, §7.3, §8.2, §10.2 |
| `cues_overlap` | §8.6, §8.7 |
| `too_many_rows` | §8.5 |
| `row_too_long` | §8.5 |
| `char_not_in_608` | §8.4, §8.5 |
| `style_not_expressible` | §8.5, §8.7 |
| `too_dense` | §8.6, §8.7 |
| `cc_malformed` | §8.8 |
A record [live-events](live-events.md) refuses keeps that extension's reason (§9 there).
## 13. Conformance
[`live-conformance/captions/`](../live-conformance/captions/) holds the corpus, written from
fixed seeds by `wai_timed_text_vectors gen`: `vectors.json` and every expected output, each a
file of its own (`.vtt`, `.ttml`, `.608.hex`). It has:
- **event vectors**: `WEV1` records in stream order, with the outcome §4.3 gives them, the
revisions, what a sink shows at stated instants (§4.4), the cues the stream resolves to or
the refusal (§4.5), and every target's output. They are a human-captioned lesson, a
machine-captioned lesson whose partials are revised to final, and a mid-stream join, both
on the events track (the joiner's records, its gap, and what it shows) and on the
timelines; and one vector for each refusal of §4.
- **cue vectors**: cues given as a list, with each target's output or refusal: every anchor
and italic placement, the disclosure label through every target, 608's special and
extended characters and substitutions, roll-up with windows of 2, 3 and 4 rows and on a
second slot clock, and one vector for each refusal of §3, §5, §6 and §8.
- **read cases**: WebVTT the reader refuses, each with its reason; and **decode cases**: 608
streams the decoder refuses.
Every 608 code path §8 uses is in a committed file: the preamble address codes of indent and
of italics, each tab offset, both mid-row codes, special and extended characters, Resume
Caption Loading, Erase Non-displayed Memory, End of Caption, Erase Displayed Memory, each
roll-up command and Carriage Return. Every reason of §12 refuses a case.
A conforming implementation gives every vector and case the outcome, the reason, the index,
the revisions, what is shown, the cues and every byte of every output the corpus states.
`wai_timed_text_vectors verify` and the test `tests/timed_text.rs` check that every file
regenerates byte for byte, that no other file is there, and that `wai-rs` gives every case its
outcome. `live-conformance/timed_text_verify.py` is the second implementation, written from
this text: it shares no code with `wai-rs`, verifies events with
`live_events_verify.py`, writes WebVTT, IMSC1, the timelines, 608 and the disclosure from §5
to §10, checks every IMSC1 document's structure (§6.3) with an XML parser and the
hypothetical render model (§6.2), and requires every mutated copy of the corpus to fail.
## 14. Security and privacy considerations
- Every revision is a signed event, and its annotation another: a relay can drop either and
cannot alter one. A dropped annotation makes its caption's annotation unknown, which a sink
shows as a plain caption (§4.4); a dropped revision is a delivery gap ([live-events](live-events.md)
§6.2).
- Only a revision's own principal can supersede it (§4.3), so one participant cannot rewrite
another's captions.
- A partial's stable prefix is a promise its producer's later revisions are checked against
(`stability_broken`); it is not a check that the speech was transcribed correctly.
- The label is the producer's statement. A model digest names a model; it does not prove that
model wrote the text. A producer that labels machine text human misleads its viewers, and
the signature on the event makes that statement attributable to it.
- Text is untrusted input. Writers escape it (§5.1, §6.1, §7) and the reader accepts only the
subset (§5.3), so cue text cannot inject markup, a cue setting or a timestamp into a target.
- The model digest can identify the vendor or version of a recognition service. A producer
that does not want to publish it can only use origin 1, which would mislabel machine text;
this extension does not offer an anonymous machine origin.

View file

@ -9,12 +9,15 @@ and the independent verifier of each, beside it.
| [`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` |
| [`recordings/`](recordings/) | [live-recording](../extensions/live-recording.md) (`wai.live.recording`) | `live_record_verify.py` |
| [`captions/`](captions/) | [timed-text](../extensions/timed-text.md) (`wai.text.timed`) | `timed_text_verify.py` |
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
`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.
`takes/`, `wai_live_compose_vectors gen` writes `compose/`, `wai_live_record_vectors gen`
writes `recordings/` and `wai_timed_text_vectors gen` writes `captions/`, each from records
authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`,
`compose/README.md`, `recordings/README.md`, `captions/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:
@ -24,4 +27,6 @@ python3 live_events_verify.py events
python3 live_rooms_verify.py rooms
python3 capture_take_verify.py takes
python3 live_compose_verify.py compose
python3 live_record_verify.py recordings
python3 timed_text_verify.py captions
```

View file

@ -0,0 +1,55 @@
# live-conformance/captions — timed text and live captions
The corpus of [`extensions/timed-text.md`](../../extensions/timed-text.md) §13: live
captions as `WEV1` events with their `WTX1` annotations, cues given as lists, and what each
maps onto: WebVTT documents and segments, IMSC1 documents, the two MoQ caption timelines,
CTA-608 byte pairs, and the disclosure label in HLS, C2PA, the catalogs and the sidecar.
**Timed-text equivalence.** For each event vector, a conforming verifier of the corpus's
room, holding every principal to live-events' default policy and offered `events` one at a
time in the order given, reaches the outcome the vector states (§4.3): `refuse` with the
`reason` and `index`, `gap` with the `gaps`, or `accept`. A vector that does not refuse also
states its `revisions`, what a sink shows at each instant of `shown` (§4.4), and the cues the
stream resolves to or the refusal (`resolve`, §4.5); a vector with a `join` states the
records a subscriber joining the events track at that group receives, its outcome and what it
shows (§4.6). For each event vector that resolves and each cue vector, `targets` states, for
each target it names, the file written or the reason refused: `webvtt` (§5.1), `segments`
(§5.2), `imsc` (§6.1, §6.2), `timelines` (§7.1–§7.3; every object byte for byte), `cc608`
(§8; the mode, the slot clock's rate, the file and what the decoder reports of it, §8.8) and
`disclosure` (§7.4, §10: the HLS tags, the session data, the sidecar, the C2PA statement and
the catalog entries, each byte for byte). `read_cases` are WebVTT texts the reader refuses
(§5.3), and `decode_cases` 608 streams the decoder refuses (§8.8), each with its reason. A
verifier that reaches another outcome, reason, index, revision, cue, rendering or byte does
not conform.
**Provenance.** Generated here: `wai_timed_text_vectors gen` writes `vectors.json` and every
expected output from events and cues authored in code, from fixed seeds. Each principal's key
and each model digest is SHA-256 of a fixed label and an index, the room id likewise, every
time is a constant, and Ed25519 signing is deterministic (RFC 8032), so every file is the
same bytes on every machine. The texts are written for the corpus, in English and Spanish.
The generator asserts, before it writes, that each vector reaches the outcome it was written
to have, that every WebVTT file reads back to its cues, that every 608 file decodes to the
rows its cues lay out, and that the corpus covers every reason of §12 and every 608 code path
of §8. No recorded data.
## Layout
- `vectors.json`: `pt0_ms` (the media time of the room's `t0`), `room`, `principals`
(`captioner`, `asr`, `host`), `models`, `event_vectors`, `cue_vectors`, `read_cases` and
`decode_cases`. A cue is written as `start`, `end`, `lang`, `origin`, `model` (lowercase
hexadecimal, or null), `anchor`, `text` and `italic` (pairs of first code point and count).
- `NAME.vtt`, `NAME.segK.vtt`: WebVTT documents and segments.
- `NAME.ttml`: IMSC1 Text Profile documents.
- `NAME.MODE[.rNUM-DEN].608.hex`: 608 byte pairs in the hexadecimal form of §8.3, on the
30000/1001 slot clock unless the name gives another rate.
## Checking
```
cargo run --no-default-features --features timed_text --bin wai_timed_text_vectors -- verify ../live-conformance/captions
python3 -I timed_text_verify.py captions
```
The first runs from `wai-rs/`, the second from `live-conformance/`. The second is the
corpus's second implementation, written from the extension's text; it shares no code with
`wai-rs`.

View file

@ -0,0 +1,230 @@
275 9420
276 9420
277 94ae
278 94ae
279 9470
280 9470
281 97a1
282 97a1
283 9137
284 9137
285 9137
286 9137
287 2080
288 91b0
289 91b0
290 2080
291 9131
292 9131
293 2080
294 9132
295 9132
296 2080
297 91b3
298 91b3
299 2080
300 9134
301 9134
302 2080
303 91b5
304 91b5
305 2080
306 91b6
307 91b6
308 2080
309 9138
310 9138
311 91b9
312 91b9
313 91ba
314 91ba
315 2080
316 913b
317 913b
318 2080
319 91bc
320 91bc
321 2080
322 913d
323 913d
324 2080
325 913e
326 913e
327 2080
328 91bf
329 91bf
330 942f
331 942f
366 9420
367 9420
368 94ae
369 94ae
370 9452
371 9452
372 97a2
373 97a2
374 4580
375 92a1
376 92a1
377 ec20
378 64e9
379 eaef
380 20a2
381 923e
382 923e
383 735e
384 a280
385 92bf
386 92bf
387 20a1
388 92a7
389 92a7
390 942c
391 942c
392 fd61
393 6e64
394 e0a1
395 94f4
396 94f4
397 fb20
398 7c20
399 2a20
400 dc20
401 5e20
402 df20
403 e020
404 fe80
405 942f
406 942f
413 9420
414 9420
415 94ae
416 94ae
417 9452
418 9452
419 c180
420 13b0
421 13b0
422 20ef
423 13b3
424 13b3
425 2073
426 1334
427 1334
428 204f
429 13ba
430 13ba
431 2061
432 13b9
433 13b9
434 2020
435 1329
436 1329
437 f820
438 132a
439 132a
440 2020
441 13ab
442 13ab
443 2020
444 132c
445 132c
446 2020
447 13ad
448 13ad
449 2020
450 13ae
451 13ae
452 2020
453 132f
454 132f
455 94f4
456 94f4
457 97a2
458 97a2
459 2080
460 92a8
461 92a8
462 2020
463 922a
464 922a
465 942c
466 942c
468 2020
469 92ab
470 92ab
471 2020
472 922c
473 922c
474 20a2
475 92ae
476 92ae
477 f1a2
478 922f
479 922f
480 942f
481 942f
494 9420
495 9420
496 94ae
497 94ae
498 9452
499 9452
500 97a2
501 97a2
502 49f4
503 a773
504 20b9
505 adb5
506 2020
507 13bc
508 13bc
509 2080
510 133d
511 133d
512 2080
513 133e
514 133e
515 2080
516 13bf
517 13bf
518 2020
519 13b5
520 13b5
521 2020
522 13b6
523 13b6
524 2020
525 1337
526 1337
527 94f4
528 94f4
529 97a1
530 97a1
531 a780
532 9226
533 9226
534 2020
535 92ad
536 92ad
537 20c1
538 1320
539 1320
540 942c
541 942c
543 20ef
544 13a8
545 13a8
546 2049
547 1323
548 1323
549 20e9
550 13a4
551 13a4
552 204f
553 1325
554 1325
555 942f
556 942f
615 942c
616 942c

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="en" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
<p begin="00:00:11.000" end="00:00:13.000" region="bottom" tts:textAlign="center">♪♪ ® ° ½ ¿ ™ ¢ £ à è â ê î ô û</p>
<p begin="00:00:13.500" end="00:00:15.500" region="bottom" tts:textAlign="center">Él dijo «sí» ¡Ñandú!<br/>ç ÷ á é í ó ú ñ</p>
<p begin="00:00:16.000" end="00:00:18.000" region="bottom" tts:textAlign="center">Ä ö ß Ø å {x} \ ^ _ | ~<br/>* — © ℠ “q”</p>
<p begin="00:00:18.500" end="00:00:20.500" region="bottom" tts:textAlign="center">It’s 9–5 ┌┐└┘ ¥ ¤ ¦<br/>‘ • Ã õ Ì ì Ò</p>
</div>
</body>
</tt>

View file

@ -0,0 +1,16 @@
WEBVTT
00:00:11.000 --> 00:00:13.000 line:90%,end position:50%,center size:80% align:center
♪♪ ® ° ½ ¿ ™ ¢ £ à è â ê î ô û
00:00:13.500 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
Él dijo «sí» ¡Ñandú!
ç ÷ á é í ó ú ñ
00:00:16.000 --> 00:00:18.000 line:90%,end position:50%,center size:80% align:center
Ä ö ß Ø å {x} \ ^ _ | ~
* — © ℠ “q”
00:00:18.500 --> 00:00:20.500 line:90%,end position:50%,center size:80% align:center
It’s 9–5 ┌┐└┘ ¥ ¤ ¦
‘ • Ã õ Ì ì Ò

View file

@ -0,0 +1,89 @@
243 9420
244 9420
245 94ae
246 94ae
247 94ce
248 94ce
249 52ef
250 ecec
251 ad75
252 7080
253 9120
254 9120
255 6be5
256 e570
257 7320
258 f468
259 e520
260 ece9
261 6ee5
262 7380
263 9470
264 9470
265 6162
266 ef76
267 e520
268 e9f4
269 20ef
270 6e20
271 73e3
272 f2e5
273 e56e
274 ae80
275 942f
276 942f
326 9420
327 9420
328 94ae
329 94ae
330 9470
331 9470
332 9723
333 9723
334 5468
335 e56e
336 2061
337 91ae
338 91ae
339 6ee5
340 f780
341 9120
342 9120
343 ece9
344 6ee5
345 20f2
346 efec
347 ec73
348 20e9
349 6eae
350 942f
351 942f
400 942c
401 942c
402 9420
403 9420
404 94ae
405 94ae
406 9470
407 9470
408 9723
409 9723
410 c1e6
411 f4e5
412 f220
413 6120
414 6761
415 702c
416 20e9
417 f420
418 73f4
419 61f2
420 f473
421 20e3
422 ece5
423 616e
424 ae80
425 942f
426 942f
475 942c
476 942c

View file

@ -0,0 +1,77 @@
330 9425
331 9425
332 946e
333 946e
334 52ef
335 ecec
336 ad75
337 7080
338 9120
339 9120
340 6be5
341 e570
342 7320
343 f468
344 e520
345 ece9
346 6ee5
347 7380
348 94ad
349 94ad
350 9470
351 9470
352 6162
353 ef76
354 e520
355 e9f4
356 20ef
357 6e20
358 73e3
359 f2e5
360 e56e
361 ae80
420 94ad
421 94ad
422 9470
423 9470
424 9723
425 9723
426 5468
427 e56e
428 2061
429 91ae
430 91ae
431 6ee5
432 f780
433 9120
434 9120
435 ece9
436 6ee5
437 20f2
438 efec
439 ec73
440 20e9
441 6eae
480 942c
481 942c
510 9470
511 9470
512 9723
513 9723
514 c1e6
515 f4e5
516 f220
517 6120
518 6761
519 702c
520 20e9
521 f420
522 73f4
523 61f2
524 f473
525 20e3
526 ece5
527 616e
528 ae80
570 942c
571 942c

View file

@ -0,0 +1,77 @@
275 9425
276 9425
277 946e
278 946e
279 52ef
280 ecec
281 ad75
282 7080
283 9120
284 9120
285 6be5
286 e570
287 7320
288 f468
289 e520
290 ece9
291 6ee5
292 7380
293 94ad
294 94ad
295 9470
296 9470
297 6162
298 ef76
299 e520
300 e9f4
301 20ef
302 6e20
303 73e3
304 f2e5
305 e56e
306 ae80
350 94ad
351 94ad
352 9470
353 9470
354 9723
355 9723
356 5468
357 e56e
358 2061
359 91ae
360 91ae
361 6ee5
362 f780
363 9120
364 9120
365 ece9
366 6ee5
367 20f2
368 efec
369 ec73
370 20e9
371 6eae
400 942c
401 942c
425 9470
426 9470
427 9723
428 9723
429 c1e6
430 f4e5
431 f220
432 6120
433 6761
434 702c
435 20e9
436 f420
437 73f4
438 61f2
439 f473
440 20e3
441 ece5
442 616e
443 ae80
475 942c
476 942c

View file

@ -0,0 +1,77 @@
330 9426
331 9426
332 946e
333 946e
334 52ef
335 ecec
336 ad75
337 7080
338 9120
339 9120
340 6be5
341 e570
342 7320
343 f468
344 e520
345 ece9
346 6ee5
347 7380
348 94ad
349 94ad
350 9470
351 9470
352 6162
353 ef76
354 e520
355 e9f4
356 20ef
357 6e20
358 73e3
359 f2e5
360 e56e
361 ae80
420 94ad
421 94ad
422 9470
423 9470
424 9723
425 9723
426 5468
427 e56e
428 2061
429 91ae
430 91ae
431 6ee5
432 f780
433 9120
434 9120
435 ece9
436 6ee5
437 20f2
438 efec
439 ec73
440 20e9
441 6eae
480 942c
481 942c
510 9470
511 9470
512 9723
513 9723
514 c1e6
515 f4e5
516 f220
517 6120
518 6761
519 702c
520 20e9
521 f420
522 73f4
523 61f2
524 f473
525 20e3
526 ece5
527 616e
528 ae80
570 942c
571 942c

View file

@ -0,0 +1,77 @@
330 94a7
331 94a7
332 946e
333 946e
334 52ef
335 ecec
336 ad75
337 7080
338 9120
339 9120
340 6be5
341 e570
342 7320
343 f468
344 e520
345 ece9
346 6ee5
347 7380
348 94ad
349 94ad
350 9470
351 9470
352 6162
353 ef76
354 e520
355 e9f4
356 20ef
357 6e20
358 73e3
359 f2e5
360 e56e
361 ae80
420 94ad
421 94ad
422 9470
423 9470
424 9723
425 9723
426 5468
427 e56e
428 2061
429 91ae
430 91ae
431 6ee5
432 f780
433 9120
434 9120
435 ece9
436 6ee5
437 20f2
438 efec
439 ec73
440 20e9
441 6eae
480 942c
481 942c
510 9470
511 9470
512 9723
513 9723
514 c1e6
515 f4e5
516 f220
517 6120
518 6761
519 702c
520 20e9
521 f420
522 73f4
523 61f2
524 f473
525 20e3
526 ece5
527 616e
528 ae80
570 942c
571 942c

View file

@ -0,0 +1,7 @@
WEBVTT
00:00:01.000 --> 00:00:03.000 line:90%,end position:50%,center size:80% align:center
one
00:00:02.000 --> 00:00:04.000 line:10%,start position:50%,center size:80% align:center
two

View file

@ -0,0 +1,109 @@
312 9420
313 9420
314 94ae
315 94ae
316 94f2
317 94f2
318 97a2
319 97a2
320 c120
321 70e5
322 f273
323 ef6e
324 20f7
325 f2ef
326 f4e5
327 20f4
328 68e9
329 73ae
330 942f
331 942f
384 9420
385 9420
386 94ae
387 94ae
388 94f2
389 94f2
390 942c
391 942c
393 97a2
394 97a2
395 c120
396 6def
397 64e5
398 ec20
399 f7f2
400 eff4
401 e520
402 f468
403 e973
404 ae80
405 942f
406 942f
445 9420
446 9420
447 94ae
448 94ae
449 9452
450 9452
451 97a2
452 97a2
453 c120
454 6def
455 64e5
456 ec20
457 f7f2
458 eff4
459 e520
460 f468
461 e973
462 2c80
463 94f2
464 94f2
465 942c
466 942c
468 616e
469 6420
470 6120
471 70e5
472 f273
473 ef6e
474 20e3
475 68e5
476 e36b
477 e564
478 20e9
479 f4ae
480 942f
481 942f
528 9420
529 9420
530 94ae
531 94ae
532 91d0
533 91d0
534 9723
535 9723
536 c16e
537 eff4
538 68e5
539 f280
540 942c
541 942c
542 91ae
543 91ae
544 6def
545 64e5
546 ec80
547 9120
548 9120
549 f7f2
550 eff4
551 e520
552 f468
553 e973
554 ae80
555 942f
556 942f
615 942c
616 942c

View file

@ -0,0 +1,8 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:10.000,MPEGTS:1800000
00:00:11.000 --> 00:00:13.000 line:90%,end position:50%,center size:80% align:center
A person wrote this.
00:00:13.500 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>A model wrote this.</c>

View file

@ -0,0 +1,12 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:15.000,MPEGTS:2250000
00:00:13.500 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>A model wrote this.</c>
00:00:16.000 --> 00:00:18.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine-reviewed.wai-model-ef0c13d7be23ebedb9e3dd853d5185f0a631d8d91b615ecb550c921fefa13181>A model wrote this,
and a person checked it.</c>
00:00:18.500 --> 00:00:20.500 line:10%,start position:50%,center size:80% align:center
<c.wai-machine.wai-model-c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0>Another <i>model</i> wrote this.</c>

View file

@ -0,0 +1,5 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:20.000,MPEGTS:2700000
00:00:18.500 --> 00:00:20.500 line:10%,start position:50%,center size:80% align:center
<c.wai-machine.wai-model-c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0>Another <i>model</i> wrote this.</c>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="en" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
<p begin="00:00:11.000" end="00:00:13.000" region="bottom" tts:textAlign="center">A person wrote this.</p>
<p begin="00:00:13.500" end="00:00:15.500" region="bottom" tts:textAlign="center" wai:origin="machine" wai:model="28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c">A model wrote this.</p>
<p begin="00:00:16.000" end="00:00:18.000" region="bottom" tts:textAlign="center" wai:origin="machine-reviewed" wai:model="ef0c13d7be23ebedb9e3dd853d5185f0a631d8d91b615ecb550c921fefa13181">A model wrote this,<br/>and a person checked it.</p>
<p begin="00:00:18.500" end="00:00:20.500" region="top" tts:textAlign="center" wai:origin="machine" wai:model="c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0">Another <span tts:fontStyle="italic">model</span> wrote this.</p>
</div>
</body>
</tt>

View file

@ -0,0 +1,14 @@
WEBVTT
00:00:11.000 --> 00:00:13.000 line:90%,end position:50%,center size:80% align:center
A person wrote this.
00:00:13.500 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>A model wrote this.</c>
00:00:16.000 --> 00:00:18.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine-reviewed.wai-model-ef0c13d7be23ebedb9e3dd853d5185f0a631d8d91b615ecb550c921fefa13181>A model wrote this,
and a person checked it.</c>
00:00:18.500 --> 00:00:20.500 line:10%,start position:50%,center size:80% align:center
<c.wai-machine.wai-model-c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0>Another <i>model</i> wrote this.</c>

View file

@ -0,0 +1,31 @@
WEBVTT
00:00:01.000 --> 00:00:01.100 line:90%,end position:50%,center size:80% align:center
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn
00:00:01.100 --> 00:00:01.200 line:90%,end position:50%,center size:80% align:center
JKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvw
00:00:01.200 --> 00:00:01.300 line:90%,end position:50%,center size:80% align:center
STUVWXYZabcdefghijklmnopqrstuvwxyz012345
00:00:01.300 --> 00:00:01.400 line:90%,end position:50%,center size:80% align:center
bcdefghijklmnopqrstuvwxyz0123456789ABCDE
00:00:01.400 --> 00:00:01.500 line:90%,end position:50%,center size:80% align:center
klmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN
00:00:01.500 --> 00:00:01.600 line:90%,end position:50%,center size:80% align:center
tuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVW
00:00:01.600 --> 00:00:01.700 line:90%,end position:50%,center size:80% align:center
23456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef
00:00:01.700 --> 00:00:01.800 line:90%,end position:50%,center size:80% align:center
BCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno
00:00:01.800 --> 00:00:01.900 line:90%,end position:50%,center size:80% align:center
KLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwx
00:00:01.900 --> 00:00:02.000 line:90%,end position:50%,center size:80% align:center
TUVWXYZabcdefghijklmnopqrstuvwxyz0123456

View file

@ -0,0 +1,136 @@
312 9420
313 9420
314 94ae
315 94ae
316 94f2
317 94f2
318 c7ef
319 ef64
320 206d
321 eff2
322 6ee9
323 6e67
324 2c20
325 e576
326 e5f2
327 79ef
328 6ee5
329 ae80
330 942f
331 942f
389 9420
390 9420
391 94ae
392 94ae
393 9454
394 9454
395 54ef
396 6461
397 7920
398 f7e5
399 20ec
400 efef
401 6b20
402 61f4
403 94f2
404 94f2
405 942c
406 942c
408 97a2
409 97a2
410 68ef
411 f720
412 f468
413 e520
414 f4e9
415 64e5
416 7320
417 f7ef
418 f26b
419 ae80
420 942f
421 942f
504 9420
505 9420
506 94ae
507 94ae
508 9152
509 9152
510 942c
511 942c
513 9723
514 9723
515 91ae
516 91ae
517 5bf7
518 6176
519 e573
520 20e3
521 f261
522 7368
523 e96e
524 675d
525 942f
526 942f
562 9420
563 9420
564 94ae
565 94ae
566 9452
567 9452
568 5468
569 e580
570 91ae
571 91ae
572 cdef
573 ef6e
574 9120
575 9120
576 7075
577 ecec
578 7320
579 f468
580 e520
581 73e5
582 612c
583 94f2
584 94f2
585 942c
586 942c
588 97a2
589 97a2
590 616e
591 6420
592 f468
593 e520
594 73e5
595 6120
596 616e
597 73f7
598 e5f2
599 73ae
600 942f
601 942f
701 9420
702 9420
703 94ae
704 94ae
705 942c
706 942c
707 9470
708 9470
709 5175
710 e573
711 f4e9
712 ef6e
713 73bf
714 20c1
715 736b
716 2062
717 e5ec
718 eff7
719 ae80
720 942f
721 942f
780 942c
781 942c

View file

@ -0,0 +1,5 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:10.000,MPEGTS:1800000
00:00:11.000 --> 00:00:13.500 line:90%,end position:50%,center size:80% align:center
Good morning, everyone.

View file

@ -0,0 +1,9 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:14.000,MPEGTS:2160000
00:00:14.000 --> 00:00:17.000 line:90%,end position:50%,center size:80% align:center
Today we look at
how the tides work.
00:00:17.500 --> 00:00:19.500 line:10%,start position:50%,center size:80% align:center
<i>[waves crashing]</i>

View file

@ -0,0 +1,9 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:18.000,MPEGTS:2520000
00:00:17.500 --> 00:00:19.500 line:10%,start position:50%,center size:80% align:center
<i>[waves crashing]</i>
00:00:20.000 --> 00:00:23.500 line:90%,end position:50%,center size:80% align:center
The <i>Moon</i> pulls the sea,
and the sea answers.

View file

@ -0,0 +1,9 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:22.000,MPEGTS:2880000
00:00:20.000 --> 00:00:23.500 line:90%,end position:50%,center size:80% align:center
The <i>Moon</i> pulls the sea,
and the sea answers.
00:00:24.000 --> 00:00:26.000 line:90%,end position:50%,center size:80% align:left
Questions? Ask below.

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="en" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
<p begin="00:00:11.000" end="00:00:13.500" region="bottom" tts:textAlign="center">Good morning, everyone.</p>
<p begin="00:00:14.000" end="00:00:17.000" region="bottom" tts:textAlign="center">Today we look at<br/>how the tides work.</p>
<p begin="00:00:17.500" end="00:00:19.500" region="top" tts:textAlign="center"><span tts:fontStyle="italic">[waves crashing]</span></p>
<p begin="00:00:20.000" end="00:00:23.500" region="bottom" tts:textAlign="center">The <span tts:fontStyle="italic">Moon</span> pulls the sea,<br/>and the sea answers.</p>
<p begin="00:00:24.000" end="00:00:26.000" region="bottom" tts:textAlign="left">Questions? Ask below.</p>
</div>
</body>
</tt>

View file

@ -0,0 +1,18 @@
WEBVTT
00:00:11.000 --> 00:00:13.500 line:90%,end position:50%,center size:80% align:center
Good morning, everyone.
00:00:14.000 --> 00:00:17.000 line:90%,end position:50%,center size:80% align:center
Today we look at
how the tides work.
00:00:17.500 --> 00:00:19.500 line:10%,start position:50%,center size:80% align:center
<i>[waves crashing]</i>
00:00:20.000 --> 00:00:23.500 line:90%,end position:50%,center size:80% align:center
The <i>Moon</i> pulls the sea,
and the sea answers.
00:00:24.000 --> 00:00:26.000 line:90%,end position:50%,center size:80% align:left
Questions? Ask below.

View file

@ -0,0 +1,67 @@
337 9420
338 9420
339 94ae
340 94ae
341 9470
342 9470
343 97a1
344 97a1
345 5468
346 e520
347 f4e9
348 64e5
349 20e3
350 ef6d
351 e573
352 20e9
353 6e20
354 f4f7
355 e9e3
356 e520
357 6120
358 6461
359 79ae
360 942f
361 942f
459 9420
460 9420
461 94ae
462 94ae
463 94f2
464 94f2
465 942c
466 942c
468 97a2
469 97a2
470 616e
471 6420
472 67ef
473 e573
474 20ef
475 75f4
476 2061
477 6761
478 e96e
479 ae80
480 942f
481 942f
570 942c
571 942c
586 9420
587 9420
588 94ae
589 94ae
590 94f4
591 94f4
592 cee5
593 f8f4
594 2c20
595 f468
596 e520
597 cdef
598 ef6e
599 ae80
600 942f
601 942f
660 942c
661 942c

View file

@ -0,0 +1,51 @@
360 9425
361 9425
362 9470
363 9470
364 97a1
365 97a1
366 5468
367 e520
368 f4e9
369 64e5
370 20e3
371 ef6d
372 e573
373 20e9
374 6e20
375 f4f7
376 e9e3
377 e520
378 6120
379 6461
380 79ae
465 942c
466 942c
480 94f2
481 94f2
482 97a2
483 97a2
484 616e
485 6420
486 67ef
487 e573
488 20ef
489 75f4
490 2061
491 6761
492 e96e
493 ae80
570 942c
571 942c
600 94f4
601 94f4
602 cee5
603 f8f4
604 2c20
605 f468
606 e520
607 cdef
608 ef6e
609 ae80
660 942c
661 942c

View file

@ -0,0 +1,5 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:10.000,MPEGTS:1800000
00:00:12.000 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>The tide comes in twice a day.</c>

View file

@ -0,0 +1,8 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:14.000,MPEGTS:2160000
00:00:12.000 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>The tide comes in twice a day.</c>
00:00:16.000 --> 00:00:19.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine-reviewed.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>and goes out again.</c>

View file

@ -0,0 +1,8 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:18.000,MPEGTS:2520000
00:00:16.000 --> 00:00:19.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine-reviewed.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>and goes out again.</c>
00:00:20.000 --> 00:00:22.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>Next, the Moon.</c>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="en" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
<p begin="00:00:12.000" end="00:00:15.500" region="bottom" tts:textAlign="center" wai:origin="machine" wai:model="28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c">The tide comes in twice a day.</p>
<p begin="00:00:16.000" end="00:00:19.000" region="bottom" tts:textAlign="center" wai:origin="machine-reviewed" wai:model="28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c">and goes out again.</p>
<p begin="00:00:20.000" end="00:00:22.000" region="bottom" tts:textAlign="center" wai:origin="machine" wai:model="28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c">Next, the Moon.</p>
</div>
</body>
</tt>

View file

@ -0,0 +1,10 @@
WEBVTT
00:00:12.000 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>The tide comes in twice a day.</c>
00:00:16.000 --> 00:00:19.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine-reviewed.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>and goes out again.</c>
00:00:20.000 --> 00:00:22.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-28be58c8cc7105cac6b0412115293f5b487b4de6403b0019ea1c9b7dc7f89b6c>Next, the Moon.</c>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="en" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
<p begin="00:01:07.000" end="00:01:11.000" region="bottom" tts:textAlign="center" wai:origin="machine" wai:model="c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0">this line still shows</p>
<p begin="00:01:08.500" end="00:01:12.300" region="bottom" tts:textAlign="center" wai:origin="machine" wai:model="c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0">we now cross the minute.</p>
<p begin="00:01:11.000" end="00:01:12.500" region="bottom" tts:textAlign="center" wai:origin="machine" wai:model="c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0">after the join</p>
</div>
</body>
</tt>

View file

@ -0,0 +1,10 @@
WEBVTT
00:01:07.000 --> 00:01:11.000 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0>this line still shows</c>
00:01:08.500 --> 00:01:12.300 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0>we now cross the minute.</c>
00:01:11.000 --> 00:01:12.500 line:90%,end position:50%,center size:80% align:center
<c.wai-machine.wai-model-c57ea8ebe4d2e130d9f5dbf5061b2955fb5b96e34ccccb0b1d8e359d6bba22b0>after the join</c>

View file

@ -0,0 +1,4 @@
WEBVTT
00:00:01.000 --> 00:00:03.000 line:90%,end position:50%,center size:80% align:center
un<i>beli</i>evable

View file

@ -0,0 +1,195 @@
311 9420
312 9420
313 94ae
314 94ae
315 94f2
316 94f2
317 97a1
318 97a1
319 4576
320 e5f2
321 7920
322 616e
323 e368
324 eff2
325 2c20
326 e96e
327 20f4
328 75f2
329 6eae
330 942f
331 942f
375 9420
376 9420
377 94ae
378 94ae
379 94d0
380 94d0
381 4ce5
382 e6f4
383 2c20
384 f7e9
385 f468
386 2061
387 91ae
388 91ae
389 62e9
390 942c
391 942c
392 6720
393 e964
394 e561
395 9470
396 9470
397 ef76
398 e5f2
399 20f4
400 f7ef
401 20ec
402 e96e
403 e573
404 ae80
405 942f
406 942f
455 9420
456 9420
457 94ae
458 94ae
459 9470
460 9470
461 9723
462 9723
463 52e9
464 6768
465 942c
466 942c
467 f4ba
468 20b5
469 203e
470 20b3
471 2026
472 2032
473 20bc
474 2034
475 20ad
476 ad3e
477 20f4
478 f275
479 e580
480 942f
481 942f
530 9420
531 9420
532 94ae
533 94ae
534 91d0
535 91d0
536 9723
537 9723
538 91ae
539 91ae
540 942c
541 942c
543 54ef
544 7020
545 e3e5
546 6ef4
547 f2e5
548 2c20
549 e9f4
550 61ec
551 e9e3
552 20f7
553 68ef
554 ece5
555 942f
556 942f
602 9420
603 9420
604 94ae
605 94ae
606 91d0
607 91d0
608 f4ef
609 7020
610 ece5
611 e6f4
612 2c20
613 f468
614 f2e5
615 942c
616 942c
617 e580
618 916e
619 916e
620 ece9
621 6ee5
622 7380
623 9120
624 9120
625 efe6
626 92d0
627 92d0
628 f4e5
629 f8f4
630 942f
631 942f
686 9420
687 9420
688 94ae
689 94ae
690 942c
691 942c
693 9158
694 9158
695 54ef
696 20f4
697 68e5
698 20f2
699 e967
700 68f4
701 2c80
702 91ae
703 91ae
704 ef6b
705 942f
706 942f
765 942c
766 942c
770 9420
771 9420
772 94ae
773 94ae
774 9476
775 9476
776 97a2
777 97a2
778 4f6e
779 e5ae
780 942f
781 942f
819 9420
820 9420
821 94ae
822 94ae
823 94f2
824 94f2
825 97a2
826 97a2
827 54f7
828 ef2c
829 91ae
830 91ae
831 73f4
832 f261
833 e967
834 68f4
835 9120
836 9120
837 61e6
838 f4e5
839 f2ae
840 942f
841 942f
900 942c
901 942c

View file

@ -0,0 +1,9 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:10.000,MPEGTS:1800000
00:00:11.000 --> 00:00:13.000 line:90%,end position:50%,center size:80% align:center
Every anchor, in turn.
00:00:13.500 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:left
Left, with a <i>big idea</i>
over two lines.

View file

@ -0,0 +1,13 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:16.000,MPEGTS:2340000
00:00:16.000 --> 00:00:18.000 line:90%,end position:50%,center size:80% align:right
Right: 5 &gt; 3 &amp; 2 &lt; 4 --&gt; true
00:00:18.500 --> 00:00:20.500 line:10%,start position:50%,center size:80% align:center
<i>Top centre, italic whole</i>
00:00:21.000 --> 00:00:23.000 line:10%,start position:50%,center size:80% align:left
top left, three
<i>lines</i> of
text

View file

@ -0,0 +1,13 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:22.000,MPEGTS:2880000
00:00:21.000 --> 00:00:23.000 line:10%,start position:50%,center size:80% align:left
top left, three
<i>lines</i> of
text
00:00:23.500 --> 00:00:25.500 line:10%,start position:50%,center size:80% align:right
To the right, <i>ok</i>
00:00:26.000 --> 00:00:28.000 line:90%,end position:50%,center size:80% align:center
One.

View file

@ -0,0 +1,5 @@
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:28.000,MPEGTS:3420000
00:00:28.000 --> 00:00:30.000 line:90%,end position:50%,center size:80% align:center
Two, <i>straight</i> after.

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" xmlns:tts="http://www.w3.org/ns/ttml#styling" xmlns:ebuttm="urn:ebu:tt:metadata" xmlns:wai="tag:transaction.science,2026:wai/timed-text" xml:lang="en" ttp:profile="http://www.w3.org/ns/ttml/profile/imsc1/text">
<head>
<metadata>
<ebuttm:documentMetadata>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1/text</ebuttm:conformsToStandard>
<ebuttm:conformsToStandard>http://www.w3.org/ns/ttml/profile/imsc1.3/text</ebuttm:conformsToStandard>
</ebuttm:documentMetadata>
</metadata>
<layout>
<region xml:id="top" tts:origin="10% 10%" tts:extent="80% 35%" tts:displayAlign="before"/>
<region xml:id="bottom" tts:origin="10% 55%" tts:extent="80% 35%" tts:displayAlign="after"/>
</layout>
</head>
<body>
<div>
<p begin="00:00:11.000" end="00:00:13.000" region="bottom" tts:textAlign="center">Every anchor, in turn.</p>
<p begin="00:00:13.500" end="00:00:15.500" region="bottom" tts:textAlign="left">Left, with a <span tts:fontStyle="italic">big idea</span><br/>over two lines.</p>
<p begin="00:00:16.000" end="00:00:18.000" region="bottom" tts:textAlign="right">Right: 5 &gt; 3 &amp; 2 &lt; 4 --&gt; true</p>
<p begin="00:00:18.500" end="00:00:20.500" region="top" tts:textAlign="center"><span tts:fontStyle="italic">Top centre, italic whole</span></p>
<p begin="00:00:21.000" end="00:00:23.000" region="top" tts:textAlign="left">top left, three<br/><span tts:fontStyle="italic">lines</span> of<br/>text</p>
<p begin="00:00:23.500" end="00:00:25.500" region="top" tts:textAlign="right">To the right, <span tts:fontStyle="italic">ok</span></p>
<p begin="00:00:26.000" end="00:00:28.000" region="bottom" tts:textAlign="center">One.</p>
<p begin="00:00:28.000" end="00:00:30.000" region="bottom" tts:textAlign="center">Two, <span tts:fontStyle="italic">straight</span> after.</p>
</div>
</body>
</tt>

View file

@ -0,0 +1,28 @@
WEBVTT
00:00:11.000 --> 00:00:13.000 line:90%,end position:50%,center size:80% align:center
Every anchor, in turn.
00:00:13.500 --> 00:00:15.500 line:90%,end position:50%,center size:80% align:left
Left, with a <i>big idea</i>
over two lines.
00:00:16.000 --> 00:00:18.000 line:90%,end position:50%,center size:80% align:right
Right: 5 &gt; 3 &amp; 2 &lt; 4 --&gt; true
00:00:18.500 --> 00:00:20.500 line:10%,start position:50%,center size:80% align:center
<i>Top centre, italic whole</i>
00:00:21.000 --> 00:00:23.000 line:10%,start position:50%,center size:80% align:left
top left, three
<i>lines</i> of
text
00:00:23.500 --> 00:00:25.500 line:10%,start position:50%,center size:80% align:right
To the right, <i>ok</i>
00:00:26.000 --> 00:00:28.000 line:90%,end position:50%,center size:80% align:center
One.
00:00:28.000 --> 00:00:30.000 line:90%,end position:50%,center size:80% align:center
Two, <i>straight</i> after.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,850 @@
#!/usr/bin/env python3
"""Independent verifier of the live-recording corpus (extensions/live-recording.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 take is verified by the capture-binding corpus's
own second implementation, capture_take_verify.py (capture-binding §9), and the room by
the live-rooms corpus's, live_rooms_verify.py (live-rooms §9), each written from its text
alone. Everything else is here:
- the package (§3): the listing of SPEC §3.1, written and checked here; the components and
their digests; the room's files and the events' chains split into their records;
- the recording manifest (§4.1, §4.2), who may sign it (§4.3), recording verification
(§4.4) and the verdicts of complete and publishable (§4.5);
- the clip record (§5.1, §5.2); the range proof of §5.3, by its recursion, and, for a run
of one chunk, by the inclusion-proof check of RFC 9162 §2.1.3.2 as that RFC states it,
which must agree; clip verification (§5.4); the time correspondence and its bound (§6);
and the FETCH address of §8.
For every vector it requires the outcome, reason and place, and the data the corpus states;
of the corpus, that every reason of §9 appears in it, and that every take it says it reuses
is capture binding's, byte for byte. Then it mutates copies of the corpus, one defect each,
and requires every copy to fail.
python3 live_record_verify.py <live-conformance/recordings>
Exit status 0 only if every vector verifies and every mutated copy fails. Python 3.8 or
later; standard library only.
"""
import copy
import hashlib
import json
import os
import re
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 capture_take_verify as CT # noqa: E402
import _ed25519 as ED # noqa: E402
Refuse = LEV.Refuse
MANIFEST_DOMAIN = b"wai:live-recording\x01"
CLIP_DOMAIN = b"wai:live-clip\x01"
LEAF_DOMAIN = b"wai:take-leaf\x01"
NODE_DOMAIN = b"wai:out-node\x01"
TAKE_DOMAIN = b"wai:capture-take\x01"
LISTING, MANIFEST_FILE = "LISTING", "recording.wrm"
ROOM, EVENTS, TAKE, COMPOSITE = 1, 2, 3, 4
COMPLETE = 1
RECORD_POWER = 8
TAKE_COMMIT, CONTROL = 0x0C, 0x09
JOIN, LEAVE, GRANT_EXCHANGE = 0x10, 0x11, 0x14
OPEN, CLOSE = 1, 3
PURPOSES = [(1, "live"), (2, "record"), (4, "distribute"), (8, "train")]
ZERO = bytes(32)
# §9, in its order.
REASONS = [
"listing_mismatch", "recording_manifest_invalid", "component_invalid", "component_digest_mismatch",
"component_unlisted", "recording_signer_not_permitted", "outside_span", "take_omitted",
"clip_record_invalid", "clip_device_mismatch", "clip_take_mismatch", "tree_head_not_committed",
"clip_out_of_range", "clip_not_included", "clip_window_invalid", "clip_chunk_missing",
]
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
class Stop(Exception):
def __init__(self, at, reason):
Exception.__init__(self, reason)
self.at, self.reason = at, reason
class Reader:
"""Little-endian fields in order; running out is `truncated`."""
def __init__(self, b, p):
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 uint(self, n):
return int.from_bytes(self.take(n), "little")
def done(self):
return self.p == len(self.b)
def read_head(b, magic):
"""live-events §3.3's first steps, with `magic` for WEV1."""
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 check_sig(key, domain, body, sig):
bad = ED.verify(key, domain + body, sig)
if bad:
raise Refuse(bad)
# ---------------------------------------------------------------- SPEC §3.1 the listing
SEGMENT = re.compile(r"[A-Za-z0-9_-]([A-Za-z0-9_.-]*[A-Za-z0-9_-])?")
def listing(files):
"""The listing of `files` (path -> bytes), or None when a path cannot be carried."""
paths = sorted(files, key=lambda p: p.encode())
if not paths:
return None
for p in paths:
if not all(SEGMENT.fullmatch(s) for s in p.split("/")):
return None
folded = [p.lower() for p in paths]
if len(set(folded)) != len(folded):
return None
if any(q.startswith(p + "/") for p in paths for q in paths):
return None
return b"".join(sha(files[p]).hex().encode() + b" " + p.encode() + b"\n" for p in paths)
def below(files, d):
return {p[len(d):]: b for p, b in files.items() if p.startswith(d)}
# ---------------------------------------------------------------- §3, §4 the package
def is_hex64(s):
return len(s) == 64 and all(c in "0123456789abcdef" for c in s)
def component_dir(kind, cid):
return {ROOM: "room/", EVENTS: "events/", TAKE: "takes/%s/" % cid}.get(kind, "composite/")
def read_manifest(b):
"""§4.1, §4.2: the recording manifest, or Refuse."""
read_head(b, b"WRM1")
r = Reader(b, 5)
m = {"room": r.take(32), "signer": r.take(32), "from": r.uint(8), "to": r.uint(8), "claims": r.uint(1),
"purposes": r.uint(1), "region": r.take(2), "components": []}
for _ in range(r.uint(2)):
kind = r.uint(1)
cid = r.take(r.uint(1))
m["components"].append({"kind": kind, "id": cid, "sha256": r.take(32)})
m["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
c = m["components"]
ok = (m["claims"] & ~COMPLETE == 0 and m["purposes"] & ~0b1111 == 0 and m["from"] <= m["to"]
and all(0x41 <= c <= 0x5A for c in m["region"]))
ok = ok and len(c) >= 2 and (c[0]["kind"], c[0]["id"]) == (ROOM, b"room") and (c[1]["kind"], c[1]["id"]) == (EVENTS, b"events")
rest = c[2:]
if ok and rest and rest[-1]["kind"] == COMPOSITE:
ok = rest[-1]["id"] == b"composite"
rest = rest[:-1]
ok = ok and all(x["kind"] == TAKE and is_hex64(x["id"].decode("latin-1")) for x in rest)
ok = ok and all(a["id"] < z["id"] for a, z in zip(rest, rest[1:]))
if not ok:
raise Refuse("recording_manifest_invalid")
check_sig(m["signer"], MANIFEST_DOMAIN, b[:-64], m["sig"])
m["digest"] = sha(b[:-64])
for x in c:
x["dir"] = component_dir(x["kind"], x["id"].decode())
return m
def split_chain(b):
"""§3.3: the records of a chain file, each read by live-events §3.3, or None."""
out = []
while b:
if len(b) < 118 or b[117] not in (0, 1):
return None
after = 118 + (48 if b[117] == 1 else 0)
if len(b) < after + 3:
return None
n = after + 3 + int.from_bytes(b[after + 1:after + 3], "little") + 64
if n > len(b):
return None
try:
LEV.read_record(b[:n])
except Refuse:
return None
out.append(b[:n])
b = b[n:]
return out
def room_files(files):
"""§3.3: (descriptor, grants, head), or None."""
d, grants, head = None, [], None
for p, b in sorted(below(files, "room/").items()):
if p == "descriptor.wrd":
d = b
elif p == "key-log.json":
try:
head = b.decode("utf-8")
except UnicodeDecodeError:
return None
else:
m = re.fullmatch(r"grant-([0-9]{4})\.wrg", p)
if not m or int(m.group(1)) != len(grants):
return None
grants.append(b)
return None if d is None else (d, grants, head)
def event_chains(files):
"""§3.3: each principal's chain, in the order of the files' paths, or None."""
out = []
for p, b in sorted(below(files, "events/").items(), key=lambda x: x[0].encode()):
m = re.fullmatch(r"([0-9a-f]{64})\.wev", p)
recs = split_chain(b) if m else None
if not recs or not all(LEV.read_record(x)["principal"].hex() == m.group(1) for x in recs):
return None
out.append(recs)
return out
def take_files(files, d):
"""§3.4: (manifest, parts, receipts, consents), or None."""
manifest, parts, receipts, consents = None, [], [], []
for p, b in sorted(below(files, d).items(), key=lambda x: x[0].encode()):
if p == "manifest.wtm":
manifest = b
continue
m = re.fullmatch(r"(chunk|receipt|consent)-([0-9]+)\.(bin|wur|wcn)", p)
if not m or (m.group(1), m.group(3)) not in (("chunk", "bin"), ("receipt", "wur"), ("consent", "wcn")):
return None
width = 10 if m.group(1) == "chunk" else 4
if len(m.group(2)) != width:
return None
k = int(m.group(2))
if m.group(1) == "chunk":
parts.append((k, b))
else:
lst = receipts if m.group(1) == "receipt" else consents
if k != len(lst):
return None
lst.append(b)
return None if manifest is None else (manifest, parts, receipts, consents)
def session_takes(d, chains, order, span):
"""§4.5 complete 2: the takes the room's events open within the span, each a start its
device answered."""
seen, starts, commits = set(), [], set()
for c, i, _ in order:
ev = LEV.read_record(chains[c][i])
if ev["digest"] in seen:
continue
seen.add(ev["digest"])
if ev["kind"] == TAKE_COMMIT:
try:
commits.add((ev["principal"], CT.read_commit(ev["payload"])["take"]))
except Refuse:
pass
elif ev["kind"] == CONTROL:
try:
p = LEV.read_payload(CONTROL, ev["payload"], 65535)
except Refuse:
continue
if p["command"] == 1 and span[0] <= ev["t"] <= span[1]:
starts.append((p["target"], sha(TAKE_DOMAIN, d["room"], p["target"], ev["digest"])))
return [t for dev, t in starts if (dev, t) in commits]
def signer_lapse(d, chains, order, held, revoked, signer, span):
"""§4.3: the first instant of the span at which `signer` does not hold the record power,
or None. The room state at t holds every join, grant_exchange and leave of the signer
the room verifier accepted with its t at or below t."""
changes, seen = [], set()
for c, i, _ in order:
ev = LEV.read_record(chains[c][i])
if ev["digest"] in seen or not LRV.is_room_key(d, ev["principal"]):
continue
seen.add(ev["digest"])
if ev["kind"] not in (JOIN, LEAVE, GRANT_EXCHANGE):
continue
p = LEV.read_payload(ev["kind"], ev["payload"], 65535)
if p["subject"] != signer:
continue
x = LRV.read_detail(ev["kind"], p["detail"])
changes.append((ev["t"], x["grant"] if ev["kind"] == JOIN else x["new"] if ev["kind"] == GRANT_EXCHANGE else None))
lo, hi = span
current = None
for t, g in changes:
if t <= lo:
current = g
segments, at = [], lo
for t, g in changes:
if lo < t <= hi:
if t > at:
segments.append((at, t - 1, current))
current, at = g, t
segments.append((at, hi, current))
by = {g["digest"]: g for g in held}
for a, z, g in segments:
g = by.get(g) if g is not None else None
if g is None or not g["powers"] & RECORD_POWER or LRV.revoked(g, revoked):
return a
if not LRV.good_at(g, d, a):
return a
if not LRV.good_at(g, d, z):
return max(a, g["exp"] * 1000000 - d["t0"])
return None
def verify_recording(files, at):
"""§4.4, §4.5: a dict of what the corpus states."""
lst = files.get(LISTING)
if lst is None or listing({p: b for p, b in files.items() if p != LISTING}) != lst:
return {"outcome": "refuse", "reason": "listing_mismatch", "at": ["listing"]}
try:
if MANIFEST_FILE not in files:
raise Stop(["manifest"], "recording_manifest_invalid")
try:
m = read_manifest(files[MANIFEST_FILE])
except Refuse as e:
raise Stop(["manifest"], e.reason)
for i, c in enumerate(m["components"]):
inner = below(files, c["dir"])
if not inner:
raise Stop(["component", i], "component_invalid")
if sha(listing(inner) or b"") != c["sha256"]:
raise Stop(["component", i], "component_digest_mismatch")
dirs = [c["dir"] for c in m["components"]]
if any(p not in (LISTING, MANIFEST_FILE) and not any(p.startswith(d) for d in dirs) for p in files):
raise Stop(["listing"], "component_unlisted")
rf = room_files(files)
if rf is None:
raise Stop(["component", 0], "component_invalid")
chains = event_chains(files)
if chains is None:
raise Stop(["component", 1], "component_invalid")
descriptor, grants, head = rf
room = LRV.verify_room(descriptor, grants, head, chains)
if room["outcome"] == "refuse":
raise Stop(["room"] + room["at"], room["reason"])
d = LRV.read_descriptor(descriptor)
if m["room"] != d["room"]:
raise Stop(["manifest"], "wrong_room")
if m["signer"] != d["authority"]:
held, _ = LRV.check_grants(d, grants)
revoked = LRV.check_key_log(d, head)
lapse = signer_lapse(d, chains, room["order"], held, revoked, m["signer"], (m["from"], m["to"]))
if lapse is not None:
raise Stop(["signer", lapse], "recording_signer_not_permitted")
if d["regions"] and m["region"] not in d["regions"]:
raise Stop(["manifest"], "store_outside_region")
times = {}
for c, i, _ in room["order"]:
ev = LEV.read_record(chains[c][i])
times[ev["digest"]] = ev["t"]
takes = []
for i, c in enumerate(m["components"]):
if c["kind"] != TAKE:
continue
tf = take_files(files, c["dir"])
if tf is None:
raise Stop(["component", i], "component_invalid")
tm, parts, receipts, consents = tf
v = {"descriptor": descriptor.hex(), "grants": [g.hex() for g in grants], "key_log_head": head,
"chains": [[r.hex() for r in ch] for ch in chains], "manifest": tm.hex(),
"parts": [{"index": k, "bytes": b.hex()} for k, b in parts], "receipts": [r.hex() for r in receipts],
"consents": [x.hex() for x in consents], "evaluated_at": at}
got = CT.verify_take(v)
if got["outcome"] == "refuse":
raise Stop(["take", i] + got["at"], got["reason"])
if got["take"] != c["id"].decode():
raise Stop(["component", i], "component_invalid")
opened = times.get(CT.read_manifest(tm, d["room"])["start"])
closed = times.get(bytes.fromhex(got["commits"][-1])) if got["commits"] else None
if opened is None or opened < m["from"] or (closed is not None and closed > m["to"]):
raise Stop(["component", i], "outside_span")
takes.append(got)
except Stop as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
# §4.5.
complete = "holds"
held = {t["take"] for t in takes}
if room["outcome"] == "gap":
complete = "delivery_gap"
elif any(t.hex() not in held for t in session_takes(d, chains, room["order"], (m["from"], m["to"]))):
complete = "take_omitted"
else:
for t in takes:
vd = t["verdicts"]
if vd["single_take"] in ("delivery_gap", "commit_chain_broken", "take_not_closed"):
complete = vd["single_take"]
break
if vd["complete"] != "holds":
complete = vd["complete"]
break
publishable = {n: next((t["verdicts"]["publishable"][n] for t in takes if t["verdicts"]["publishable"][n] != "holds"), "holds")
for _, n in PURPOSES}
stands = ((not m["claims"] & COMPLETE or complete == "holds")
and all(not m["purposes"] & b or publishable[n] == "holds" for b, n in PURPOSES))
return {"outcome": "verdicts", "package": sha(lst).hex(), "manifest": m["digest"].hex(), "room": d["room"].hex(),
"complete": complete, "publishable": publishable, "stands": stands,
"takes": [{"take": t["take"], "single_take": t["verdicts"]["single_take"], "aligned": t["verdicts"]["aligned"],
"complete": t["verdicts"]["complete"], "publishable": t["verdicts"]["publishable"],
"stands": t["stands"]} for t in takes]}
# ---------------------------------------------------------------- §5.3 the range proof, RFC 9162
def leaf(desc):
return sha(LEAF_DOMAIN, desc)
def node(a, b):
return sha(NODE_DOMAIN, a, b)
def split(n):
"""The largest power of two below n, n >= 2."""
k = 1
while k * 2 < n:
k *= 2
return k
def mth(ls):
"""RFC 9162 §2.1.1, with the take tree's node; n >= 1."""
if len(ls) == 1:
return ls[0]
k = split(len(ls))
return node(mth(ls[:k]), mth(ls[k:]))
def path(m, d):
"""RFC 6962 §2.1.1's PATH(m, D[n])."""
if len(d) <= 1:
return []
k = split(len(d))
return path(m, d[:k]) + [mth(d[k:])] if m < k else path(m - k, d[k:]) + [mth(d[:k])]
def range_nodes(a, b, d):
"""§5.3's RANGE(a, b, D[n])."""
n = len(d)
if a == 0 and b == n:
return []
k = split(n)
if b <= k:
return range_nodes(a, b, d[:k]) + [mth(d[k:])]
if a >= k:
return range_nodes(a - k, b - k, d[k:]) + [mth(d[:k])]
return range_nodes(a, k, d[:k]) + range_nodes(0, b - k, d[k:])
def range_root(a, b, n, ls, nodes):
"""§5.3's ROOT(a, b, n, L), taking nodes from the front of `nodes`; None when they run out."""
if a == 0 and b == n:
return mth(ls)
k = split(n)
if b <= k:
x = range_root(a, b, k, ls, nodes)
return None if x is None or not nodes else node(x, nodes.pop(0))
if a >= k:
x = range_root(a - k, b - k, n - k, ls, nodes)
return None if x is None or not nodes else node(nodes.pop(0), x)
x = range_root(a, k, k, ls[:k - a], nodes)
y = None if x is None else range_root(0, b - k, n - k, ls[k - a:], nodes)
return None if y is None else node(x, y)
def range_verifies(first, ls, proof, size, root):
"""§5.3: the proof shows `ls` are leaves `first` onward of the tree (size, root)."""
if not ls or first + len(ls) > size:
return False
nodes = list(proof)
r = range_root(first, first + len(ls), size, ls, nodes)
return r is not None and not nodes and r == root
def rfc9162_inclusion(leaf_hash, leaf_index, tree_size, inclusion_path, root_hash):
"""RFC 9162 §2.1.3.2, as its steps state it, with node() for HASH(0x01 || ...)."""
if leaf_index >= tree_size:
return False
fn, sn = leaf_index, tree_size - 1
r = leaf_hash
for p in inclusion_path:
if sn == 0:
return False
if fn & 1 or fn == sn:
r = node(p, r)
if not fn & 1:
while not fn & 1 and fn != 0:
fn >>= 1
sn >>= 1
else:
r = node(r, p)
fn >>= 1
sn >>= 1
return sn == 0 and r == root_hash
def self_test():
"""The range check against RFC 9162 §2.1.3.2 and RFC 6962's PATH: the failures."""
fails = []
ls = [sha(b"t", bytes([i])) for i in range(22)]
for n in range(1, 22):
d, root = ls[:n], mth(ls[:n])
for a in range(n):
p = path(a, d)
if range_nodes(a, a + 1, d) != p:
fails.append("RANGE(%d, %d) is not PATH at n = %d" % (a, a + 1, n))
if not rfc9162_inclusion(d[a], a, n, p, root) or not range_verifies(a, [d[a]], p, n, root):
fails.append("an inclusion proof does not verify at n = %d, a = %d" % (n, a))
for bad in ([x for x in p[:-1]] + [bytes(32)] if p else [], p + [bytes(32)]):
if rfc9162_inclusion(d[a], a, n, bad, root) != range_verifies(a, [d[a]], bad, n, root):
fails.append("the two checks disagree at n = %d, a = %d" % (n, a))
for b in range(a + 1, n + 1):
q = range_nodes(a, b, d)
if not range_verifies(a, d[a:b], q, n, root):
fails.append("RANGE(%d, %d) does not verify at n = %d" % (a, b, n))
if b < n and range_verifies(a + 1, d[a:b], q, n, root):
fails.append("a moved run verifies at n = %d" % n)
if q and range_verifies(a, d[a:b], q[:-1], n, root):
fails.append("a short proof verifies at n = %d" % n)
return fails
# ---------------------------------------------------------------- §5 clips
def read_clip(b):
"""§5.1, §5.2: the clip record, or Refuse."""
read_head(b, b"WCL1")
r = Reader(b, 5)
c = {"room": r.take(32), "in": r.uint(8), "out": r.uint(8), "parts": []}
for _ in range(r.uint(1)):
p = {"device": r.take(32), "take": r.take(32), "head": r.take(32), "size": r.uint(8), "root": r.take(32),
"first": r.uint(8)}
count = r.uint(4)
p["trim_in"], p["trim_out"] = r.uint(8), r.uint(8)
p["chunks"] = [CT.read_chunk(CT.Reader(r.take(64), 0, "truncated")) for _ in range(count)]
p["proof"] = [r.take(32) for _ in range(r.uint(1))]
c["parts"].append(p)
c["signer"] = r.take(32)
c["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
ps = c["parts"]
ok = 1 <= len(ps) <= 16 and len({p["take"] for p in ps}) == len(ps) and c["in"] < c["out"]
trimmed = any(p["trim_in"] or p["trim_out"] for p in ps)
ok = ok and (not trimmed or len(ps) == 1)
for p in ps:
ch = p["chunks"]
ok = (ok and len(ch) >= 1 and all(x["index"] == p["first"] + i and x["len"] >= 1 for i, x in enumerate(ch))
and len(p["proof"]) <= 128 and p["trim_in"] < ch[0]["len"] and p["trim_out"] < ch[-1]["len"]
and (len(ch) > 1 or p["trim_in"] + p["trim_out"] < ch[0]["len"]))
if not ok:
raise Refuse("clip_record_invalid")
check_sig(c["signer"], CLIP_DOMAIN, b[:-64], c["sig"])
c["digest"] = sha(b[:-64])
return c
def verify_part(k, p, given, d, clip):
"""§5.4 step 3 for part k: its facts, or Stop."""
I, J = d["commit_interval_ms"] * 1000, d["jitter_ms"] * 1000
at_part = ["part", k]
if bytes.fromhex(given["device"]) != p["device"]: # (a)
raise Stop(at_part, "clip_device_mismatch")
held = []
for j, h in enumerate(given["commits"]): # (b)
at = ["commit", k, j]
b = bytes.fromhex(h)
try:
ev = LEV.read_record(b)
except Refuse as e:
raise Stop(at, e.reason)
if ev["room"] != d["room"]:
raise Stop(at, "wrong_room")
if ev["principal"] != p["device"]:
raise Stop(at, "clip_device_mismatch")
bad = ED.verify(ev["principal"], LEV.SIGN_DOMAIN + ev["body"], ev["sig"])
if bad:
raise Stop(at, bad)
if ev["kind"] != TAKE_COMMIT:
raise Stop(at, "commit_malformed")
try:
c = CT.read_commit(ev["payload"])
except Refuse as e:
raise Stop(at, e.reason)
if c["take"] != p["take"]:
raise Stop(at, "clip_take_mismatch")
held.append((ev, c))
if not held: # (c)
raise Stop(at_part, "commit_chain_broken")
if not CT.is_opening(held[0][1]):
raise Stop(["commit", k, 0], "commit_chain_broken")
for j in range(1, len(held)):
(pe, pc), (_, c) = held[j - 1], held[j]
if c["n"] > pc["n"] + 1:
raise Stop(["commit", k, j], "delivery_gap")
if not (pc["phase"] != CLOSE and c["phase"] != OPEN and c["n"] == pc["n"] + 1 and c["prev"] == pe["digest"]
and c["first"] == pc["size"]):
raise Stop(["commit", k, j], "commit_chain_broken")
for j in range(1, len(held)):
pc, c = held[j - 1][1], held[j][1]
if not CT.extends(c["first"], pc["root"], c["size"], c["root"], c["proof"]):
raise Stop(["commit", k, j], "root_not_extending")
hev, hc = held[-1] # (d)
if hev["digest"] != p["head"] or hc["size"] != p["size"] or hc["root"] != p["root"]:
raise Stop(at_part, "tree_head_not_committed")
ch = p["chunks"]
if p["first"] + len(ch) > p["size"]: # (e)
raise Stop(at_part, "clip_out_of_range")
if not range_verifies(p["first"], [leaf(x["desc"]) for x in ch], p["proof"], p["size"], p["root"]): # (f)
raise Stop(at_part, "clip_not_included")
if len(ch) == 1 and rfc9162_inclusion(leaf(ch[0]["desc"]), p["first"], p["size"], p["proof"], p["root"]) is not True:
raise Stop(at_part, "clip_not_included")
o = held[0][1]["offset"]
for j, (_, c) in enumerate(held): # (g)
if abs(c["offset"] - o) > J:
raise Stop(["commit", k, j], "clock_unstable")
if any(x["start"] >= x["end"] for x in ch) or any(b["start"] != a["end"] for a, b in zip(ch, ch[1:])): # (h)
raise Stop(at_part, "capture_discontinuous")
if any(x["end"] - x["start"] > I for x in ch):
raise Stop(at_part, "chunk_too_long")
def adder(x):
return next(ev for ev, c in held if c["first"] <= x["index"] < c["size"])
if any(adder(x)["t"] + J < x["end"] + o for x in ch): # (i)
raise Stop(at_part, "commit_early")
if any(adder(x)["t"] > x["end"] + o + I + J for x in ch):
raise Stop(at_part, "commit_late")
span = (ch[0]["start"] + o, ch[-1]["end"] + o) # (j)
wi, wo = clip["in"], clip["out"]
covers = span[0] <= wi and wo <= span[1] and wi < ch[0]["end"] + o and ch[-1]["start"] + o < wo
if not covers or ((p["trim_in"] or p["trim_out"]) and (wi, wo) != span):
raise Stop(at_part, "clip_window_invalid")
shown = b"" # (k)
for j, x in enumerate(ch):
h = given["bytes"][j] if j < len(given["bytes"]) else None
if h is None:
raise Stop(["chunk", k, j], "clip_chunk_missing")
b = bytes.fromhex(h)
if not CT.is_chunk(x, b):
other = any(y["index"] != x["index"] and CT.is_chunk(y, b) for y in ch)
raise Stop(["chunk", k, j], "chunk_reordered" if other else "chunk_altered")
shown += b
if len(given["bytes"]) > len(ch):
raise Stop(["chunk", k, len(ch)], "chunk_extra")
shown = shown[p["trim_in"]:len(shown) - p["trim_out"]]
return {"take": p["take"].hex(), "first": p["first"], "count": len(ch), "offset": o, "span": list(span),
"device_window": [wi - o, wo - o], "bytes": len(shown), "sha256": sha(shown).hex(),
"fetch": {"namespace": [d["room"].hex(), p["device"].hex(), "take"], "name": p["take"].hex(),
"start_group": p["first"], "start_object": 0, "end_group_delta": len(ch) - 1}}
def verify_clip(v):
"""§5.4: a dict of what the corpus states."""
try:
try:
d = LRV.read_descriptor(bytes.fromhex(v["descriptor"]))
except Refuse as e:
raise Stop(["descriptor"], e.reason)
try:
clip = read_clip(bytes.fromhex(v["record"]))
except Refuse as e:
raise Stop(["clip"], e.reason)
if clip["room"] != d["room"]:
raise Stop(["clip"], "wrong_room")
if len(v["parts"]) != len(clip["parts"]):
raise Stop(["clip"], "clip_record_invalid")
facts = [verify_part(k, p, g, d, clip) for k, (p, g) in enumerate(zip(clip["parts"], v["parts"]))]
except Stop as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
J = d["jitter_ms"] * 1000
return {"outcome": "accept", "clip": clip["digest"].hex(), "signer": clip["signer"].hex(), "window": [clip["in"], clip["out"]],
"bound_us": J if len(facts) == 1 else 2 * J, "facts": facts}
# ---------------------------------------------------------------- the corpus
def files_of(v):
return {p: bytes.fromhex(h) for p, h in v["files"].items()}
def problems(v, got):
out = [k for k in got if v.get(k) != got[k]]
return out + [k for k in ("outcome", "reason", "at") if k in v and k not in got]
def check(doc, quiet=False):
"""Every vector against its statement: the failures."""
fails = []
for kind, f in (("recordings", lambda v: verify_recording(files_of(v), v["evaluated_at"])), ("clips", verify_clip)):
for v in doc.get(kind, []):
try:
got = f(v)
except Exception as e: # a crash on a mutated copy is a failure to read it
got, bad = None, ["the vector does not verify: %s: %s" % (type(e).__name__, e)]
else:
bad = problems(v, got)
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
print(("PASS" if not bad else "FAIL") + " %s %s: %s" % (kind[:-1], v["name"], what))
for k in bad:
fails.append("%s %s: %s is %s, the corpus states %s" % (
kind[:-1], v["name"], k, json.dumps(got.get(k))[:200] if got else k, json.dumps(v.get(k))[:200]))
return fails
def reuse_fails(doc, takes):
"""Every take the corpus says it reuses is capture binding's, byte for byte."""
by = {v["name"]: v for v in takes["vectors"]}
fails, n = [], 0
for v in doc["recordings"]:
for r in v["reuses"]:
t, d, f = by[r["vector"]], "takes/%s/" % r["take"], v["files"]
parts = sorted((int(p[len(d) + 6:len(d) + 16]), h) for p, h in f.items() if p.startswith(d + "chunk-"))
same = (t["take"] == r["take"] and f.get(d + "manifest.wtm") == t["manifest"]
and parts == [(p["index"], p["bytes"]) for p in t["parts"]]
and [h for p, h in sorted(f.items()) if p.startswith(d + "receipt-")] == t["receipts"]
and [h for p, h in sorted(f.items()) if p.startswith(d + "consent-")] == t["consents"])
if not same:
fails.append("recording %s: its take %s is not %s" % (v["name"], r["take"][:12], r["vector"]))
n += 1
for c in doc["clips"]:
for p in c["parts"]:
if p["take_vector"] is None:
continue
t = by[p["take_vector"]]
records = {x for ch in t["chains"] for x in ch}
parts = {x["bytes"] for x in t["parts"]}
if not all(x in records for x in p["commits"]) or not all(b is None or b in parts for b in p["bytes"]):
fails.append("clip %s: a part is not %s's" % (c["name"], p["take_vector"]))
n += 1
if n == 0:
fails.append("the corpus reuses no take")
return fails
def flip(h, at):
b = bytearray(bytes.fromhex(h))
b[at] ^= 0x01
return b.hex()
def mutations(doc):
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
rn = {v["name"]: k for k, v in enumerate(doc["recordings"])}
cn = {v["name"]: k for k, v in enumerate(doc["clips"])}
def mutate(label, f):
d = copy.deepcopy(doc)
f(d["recordings"], d["clips"])
return label, d
def first_chunk(v):
return next(p for p in sorted(v["files"]) if "/chunk-" in p)
good, single, aligned = rn["lesson_session"], cn["single_chunk"], cn["two_takes_aligned"]
return [
mutate("a byte of the session's first chunk file", lambda rs, cs: rs[good]["files"].update({first_chunk(rs[good]): flip(rs[good]["files"][first_chunk(rs[good])], 3)})),
mutate("the session's listing dropped", lambda rs, cs: rs[good]["files"].pop("LISTING")),
mutate("a byte of the session's manifest signature", lambda rs, cs: rs[good]["files"].update({"recording.wrm": flip(rs[good]["files"]["recording.wrm"], -9)})),
mutate("the session's stated package digest", lambda rs, cs: rs[good].update(package="00" * 32)),
mutate("the session evaluated a year on", lambda rs, cs: rs[good].update(evaluated_at=rs[good]["evaluated_at"] + 365 * 86400)),
mutate("the omitted take stated complete", lambda rs, cs: rs[rn["take_omitted"]].update(complete="holds")),
mutate("the missing chunk stated complete", lambda rs, cs: rs[rn["missing_chunk"]].update(complete="holds")),
mutate("the unpermitted signer stated at the listing", lambda rs, cs: rs[rn["signer_not_permitted"]].update(at=["listing"])),
mutate("the removed signer's power stated lost a second early", lambda rs, cs: rs[rn["signer_removed"]].update(at=["signer", 17000000])),
mutate("the late recorder's span stated from 0", lambda rs, cs: rs[rn["signer_power_late"]].update(at=["signer", 1000000])),
mutate("a byte of the single chunk's bytes", lambda rs, cs: cs[single]["parts"][0]["bytes"].__setitem__(0, flip(cs[single]["parts"][0]["bytes"][0], 2))),
mutate("a byte of the single chunk's record", lambda rs, cs: cs[single].update(record=flip(cs[single]["record"], 200))),
mutate("the single chunk's last commitment dropped", lambda rs, cs: cs[single]["parts"][0]["commits"].pop()),
mutate("the single chunk's stated digest", lambda rs, cs: cs[single]["facts"][0].update(sha256="00" * 32)),
mutate("the aligned clip's stated bound", lambda rs, cs: cs[aligned].update(bound_us=500000)),
mutate("the aligned clip's parts given in each other's place", lambda rs, cs: cs[aligned]["parts"].reverse()),
mutate("the byte range's stated fetch", lambda rs, cs: cs[cn["byte_range"]]["facts"][0]["fetch"].update(start_group=3)),
mutate("the byte range's stated window on the device clock", lambda rs, cs: cs[cn["byte_range"]]["facts"][0]["device_window"].__setitem__(0, 0)),
mutate("the live head's commitment 2 dropped", lambda rs, cs: cs[cn["live_head"]]["parts"][0]["commits"].pop()),
mutate("the missing chunk given chunk 5's bytes", lambda rs, cs: cs[cn["missing_chunk"]]["parts"][0]["bytes"].__setitem__(1, cs[cn["missing_chunk"]]["parts"][0]["bytes"][0])),
mutate("the spliced clip stated accepted", lambda rs, cs: cs[cn["spliced_two_takes"]].update(outcome="accept")),
mutate("the uncommitted head stated at part 1", lambda rs, cs: cs[cn["uncommitted_head"]].update(at=["part", 1])),
mutate("the clean span's late commitment dropped", lambda rs, cs: cs[cn["late_take_clean_span"]]["parts"][0]["commits"].pop(3)),
]
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
fails += ["ed25519 self-test"] if st else []
rt = self_test()
print(("PASS" if not rt else "FAIL") + " range proofs: §5.3's recursion against RFC 9162 §2.1.3.2 and RFC 6962's PATH")
fails += rt
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
with open(os.path.join(corpus_dir, "..", "takes", "vectors.json"), encoding="utf-8") as f:
takes = json.load(f)
fails += check(doc)
seen = {v.get("reason") for v in doc["recordings"] + doc["clips"]} | {v.get("complete") for v in doc["recordings"]}
missing = [r for r in REASONS if r not in seen]
if missing:
fails.append("no vector gives %s" % ", ".join(missing))
good = [v["name"] for v in doc["recordings"] if v["name"] in ("lesson_session", "signed_by_director", "with_composite", "late_recorder_span") and not v.get("stands")]
good += [v["name"] for v in doc["clips"] if v["name"] in ("single_chunk", "byte_range", "whole_take", "first_chunk",
"last_chunk", "two_takes_aligned", "live_head") and v["outcome"] != "accept"]
if good:
fails.append("good vectors that do not stand or are not accepted: %s" % ", ".join(good))
print("%s every reason of §9 appears, the good recordings stand and the good clips are accepted" % ("PASS" if not (missing or good) else "FAIL"))
rf = reuse_fails(doc, takes)
print("%s every take the corpus reuses is capture binding's, byte for byte" % ("PASS" if not rf else "FAIL"))
fails += rf
for label, mutated in mutations(doc):
changed = {k: [v for v, o in zip(mutated[k], doc[k]) if v != o] for k in ("recordings", "clips")}
caught = check(changed, quiet=True) if any(changed.values()) else []
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:110] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " live-recording corpus: %d recordings, %d clips" % (len(doc["recordings"]), len(doc["clips"])))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: live_record_verify.py <live-conformance/recordings>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,122 @@
# live-conformance/recordings — live recording
The corpus of [`extensions/live-recording.md`](../../extensions/live-recording.md) §10:
recordings, each a package (its files by path: the signed `WRM1` recording manifest, the
room's `WRD1` descriptor, `WRG1` grants and key-log head, each principal's `WEV1` chain, and
each take's `WTM1` manifest, chunks, `WUR1` receipts and `WCN1` consents, with the `LISTING`
of them all) and an evaluation instant; and clips, each a room descriptor, a signed `WCL1`
clip record and, for each of its parts, the device's key, the take's `take_commit` events up
to the head and the chunks' bytes; with the outcome a verifier gives each.
**Recording equivalence.** For each recording, a conforming recording verifier given its
`files` (path to bytes, in hexadecimal) and its `evaluated_at` (seconds since 1970) reaches
the outcome the vector states (§4.5):
- `refuse`: the `reason` and where, `at`: `["listing"]`, `["manifest"]`, `["signer", t]`
with the first instant `t` of the span at which the signer does not hold the record power
(§4.3), `["component", i]` for the manifest's component `i`, `["room", …]` with the room
verifier's own place (live-rooms §9.1), or `["take", i, …]` with the take verifier's own
place in component `i` (capture-binding §9.1);
- `verdicts`: the `package` digest (SHA-256 of `LISTING`), the recording `manifest`'s
digest, the `room` id, `complete`, `publishable` for each of `live`, `record`,
`distribute` and `train` (each `holds` or the reason it fails), whether the manifest
`stands`, and each take's id and verdicts (`single_take`, `aligned`, `complete`,
`publishable`, `stands`).
**Clip equivalence.** For each clip, a conforming clip verifier given its `descriptor`, its
`record` and its `parts` (each a `device` key, its `commits` in order, and its chunks'
`bytes`, each hexadecimal or `null` for absent) reaches the outcome the vector states
(§5.4):
- `refuse`: the `reason` and where, `at`: `["descriptor"]`, `["clip"]`, `["part", k]`,
`["commit", k, j]` for commitment `j` of part `k`, or `["chunk", k, j]` for chunk `j` of
part `k`;
- `accept`: the record's digest `clip`, its `signer`, its `window`, its `bound_us` (§6.2),
and the `facts` of each part: its `take`, `first`, `count`, the take's `offset`, its
`span` on the room clock, its `device_window`, the length (`bytes`) and `sha256` of the
bytes it presents, and its MoQ `fetch` address (§8).
A verifier that reaches another outcome, reason, place, verdict, digest or fact does not
conform.
**Provenance.** Generated here: `wai_live_record_vectors gen` writes every file, record and
byte from sessions authored in code, from fixed seeds, by capture binding's corpus rules:
each key is SHA-256 of that corpus's fixed label and the principal's index (two principals
it does not use, the accompanist and the accompanist's device, take indexes of their own),
each chunk's bytes SHA-256 output under a fixed label and the chunk's index (they stand for
an encoded master and encode nothing), every time is a constant (the room's `t0` is
2026-10-16T00:00:00Z), and Ed25519 signing is deterministic (RFC 8032), so the file is the
same bytes on every machine. A take written from one of capture binding's plans is that
corpus's take byte for byte; the generator asserts that each vector reaches the outcome it
was written to have before it writes it. The key-log heads are sealed by the reference's key
log under a fixed root. The origins are `.example` names (RFC 2606). No recorded data.
## Layout
One file, `vectors.json`:
- `principals`: the keys, by name (informative).
- `recordings`: each with `name`, `about`, `files`, `evaluated_at`, `reuses` (the take
components that are capture binding's takes: each `take` id and the `vector` of
`../takes/vectors.json` it is), `outcome`, and as above `reason`, `at`, `package`,
`manifest`, `room`, `complete`, `publishable`, `stands` and `takes`.
- `clips`: each with `name`, `about`, `descriptor`, `record`, `parts` (each with `device`,
`commits`, `bytes`, and `take_vector`, the vector of `../takes/vectors.json` whose
records every commitment and chunk of the part are, or `null`), `outcome`, and as above
`reason`, `at`, `clip`, `signer`, `window`, `bound_us` and `facts`.
Every session is a lesson room as capture binding's corpus has it (a director with the
record power, an audience, a recording policy of mode 1 with a two-second commit interval
and 500 ms of jitter, pinned to DE and FR, a key log listing the room authority and the
store), here with up to two takes:
- `lesson_session`: the performer's take, capture binding's `lesson` take, and the
accompanist's, eight chunks of 1.25 s on a device whose clock runs three seconds ahead,
sealed by the room authority; `signed_by_director`, the same sealed by the director;
`with_composite`, the same with a composite component, whose one file stands for a
composed object and encodes nothing; `late_recorder_span`, a director that joins at 1 s
sealing the span from 1 s;
- the record power over the span (§4.3), each refused at the instant it lapses:
`signer_power_late` (the same director sealing from 0), `signer_removed` (removed at
18 s), `signer_power_exchanged` (exchanged at 17 s to a grant without the power) and
`signer_grant_expired` (its grant expires at 18 s); and `signer_grant_revoked`, whose
grant the key log revokes, refused at the room;
- recordings that fail a claim: `take_omitted`, `missing_chunk` (capture binding's
`missing_chunk` take), `commit_gap` and `consent_missing`;
- recordings refused: `listing_mismatch`, `component_digest_mismatch`,
`composite_digest_mismatch`,
`component_unlisted`, `component_invalid`, `recording_manifest_invalid`,
`manifest_bad_signature`, `manifest_wrong_room`, `signer_not_permitted`,
`outside_region`, `outside_span`, `take_refused` and `room_refused`;
- good clips: `single_chunk`, `byte_range`, `whole_take`, `first_chunk`, `last_chunk`,
`accompanist_span`, `live_head`, `two_takes_aligned` and `late_take_clean_span`;
- clips refused, one defect each: `proof_not_reaching_head`, `range_past_size`,
`missing_chunk`, `spliced_two_takes`, `reordered_chunk`, `altered_chunk`, `extra_chunk`,
`uncommitted_head`, `commit_gap`, `commit_malformed`, `take_mismatch`,
`device_mismatch`, `clip_wrong_room`, `clip_bad_signature`, `clip_record_invalid`,
`window_not_covered`, `trimmed_window`, `clip_truncated`, and spans of capture binding's
defective takes: `commit_late`, `commit_early`, `root_not_extending`, `clock_unstable`,
`capture_discontinuous`, `chunk_too_long` and `commit_chain_broken`.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_record --bin wai_live_record_vectors -- verify ../live-conformance/recordings
cargo run --no-default-features --features live_record --bin wai_live_record_vectors -- gen ../live-conformance/recordings # regenerate
cargo test --no-default-features --features live_record --test live_record
cd ../live-conformance
python3 live_record_verify.py recordings
```
`wai_live_record_vectors verify` and `tests/live_record.rs` require the file to regenerate
byte for byte and `wai-rs` to give every vector its outcome; the test also requires every
reason of §9 to appear, the good recordings to stand and the good clips to be accepted, §9
to list exactly the reasons the reference reports, and every take the corpus says it reuses
to be capture binding's, byte for byte, against `../takes/vectors.json`.
`live_record_verify.py` is the second implementation, written from the extension's text
and sharing no code with `wai-rs`: it checks range proofs by the extension's recursion and,
for a run of one chunk, by RFC 9162 §2.1.3.2's algorithm, verifies each take with
`capture_take_verify.py` (which verifies the room with `live_rooms_verify.py`), gives every
vector the same outcome, requires the same coverage and reuse, and then mutates copies of
the corpus, one defect each, each of which must fail. Pass = an exit status of 0 from each.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -586,6 +586,20 @@
"edition": null,
"registered": "2026-10-16"
},
{
"capability": "wai.live.recording",
"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.room",
"kind": "record",
@ -1193,6 +1207,20 @@
"edition": null,
"registered": "2026-07-30"
},
{
"capability": "wai.text.timed",
"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.text.xz",
"kind": "payload",
@ -1706,6 +1734,13 @@
"section": "§3.1",
"status": "defined"
},
{
"name": "WCL1",
"kind": "magic",
"document": "extensions/live-recording.md",
"section": "§5.1",
"status": "defined"
},
{
"name": "WCN1",
"kind": "magic",
@ -1755,6 +1790,13 @@
"section": "§4.1",
"status": "defined"
},
{
"name": "WRM1",
"kind": "magic",
"document": "extensions/live-recording.md",
"section": "§4.1",
"status": "defined"
},
{
"name": "WSIX",
"kind": "magic",
@ -1769,6 +1811,13 @@
"section": "§7.1",
"status": "defined"
},
{
"name": "WTX1",
"kind": "magic",
"document": "extensions/timed-text.md",
"section": "§4.2",
"status": "defined"
},
{
"name": "WUR1",
"kind": "magic",
@ -1839,6 +1888,13 @@
"section": "§8",
"status": "defined"
},
{
"name": "science.transaction.wai.timed-text",
"kind": "data-id",
"document": "extensions/timed-text.md",
"section": "§10.5",
"status": "defined"
},
{
"name": "tag:transaction.science,2026:wai/class",
"kind": "scheme",
@ -1860,6 +1916,13 @@
"section": "§11.1",
"status": "defined"
},
{
"name": "tag:transaction.science,2026:wai/model/",
"kind": "scheme",
"document": "extensions/timed-text.md",
"section": "§10.3",
"status": "defined"
},
{
"name": "tag:transaction.science,2026:wai/stages",
"kind": "scheme",
@ -1867,6 +1930,13 @@
"section": "§8",
"status": "defined"
},
{
"name": "tag:transaction.science,2026:wai/timed-text",
"kind": "scheme",
"document": "extensions/timed-text.md",
"section": "§10.4",
"status": "defined"
},
{
"name": "urn:wai:event:1",
"kind": "scheme",
@ -1979,6 +2049,27 @@
"section": "§4",
"status": "defined"
},
{
"name": "wai_machine_generated",
"kind": "catalog",
"document": "extensions/timed-text.md",
"section": "§7.4",
"status": "defined"
},
{
"name": "wai_model",
"kind": "catalog",
"document": "extensions/timed-text.md",
"section": "§7.2",
"status": "defined"
},
{
"name": "wai_origin",
"kind": "catalog",
"document": "extensions/timed-text.md",
"section": "§7.2",
"status": "defined"
},
{
"name": "wai_receipt_alias",
"kind": "catalog",

View file

@ -536,6 +536,13 @@ wai:wai.live.events a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-16"^^xsd:date .
wai:wai.live.recording a wai:Capability ;
rdfs:label "wai.live.recording" ;
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.room a wai:Capability ;
rdfs:label "wai.live.room" ;
wai:kind wai:kind-record ;
@ -1002,6 +1009,13 @@ wai:wai.ternary.tt a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-07-30"^^xsd:date .
wai:wai.text.timed a wai:Capability ;
rdfs:label "wai.text.timed" ;
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.text.xz a wai:Capability ;
rdfs:label "wai.text.xz" ;
wai:kind wai:kind-payload ;

View file

@ -199,6 +199,20 @@ capture_take = ["live_room"]
# The layout and the mixer use nothing beyond core and alloc. Ed25519 and SHA-256 only; no
# new dependency.
live_compose = ["live_room"]
# Live recording (extensions/live-recording): `wai.live.recording` — a finished room session as
# one WAI package (the SPEC §3.1 listing of its files; the room, its events, each take and the
# composite as components named by id and the SHA-256 of their listings; the signed WRM1
# recording manifest) with its complete and publishable verdicts, and provable clips: WCL1 clip
# records whose RFC 6962 range proofs reach a take's commitment, verified without the take.
# SHA-256, Ed25519 and the key log only; no new dependency.
live_record = ["capture_take"]
# Timed text and live captions (extensions/timed-text): `wai.text.timed` — the cue model,
# live caption revisions (`caption` events of wai.live.events with the WTX1 annotation),
# and their exact mappings onto WebVTT documents and segments, the IMSC1 Text Profile,
# the MoQ caption timelines, CTA-608 (CC1, pop-on and roll-up) and HLS, with the
# machine-generation disclosure carried through each. No new dependency: the XML is
# written by hand.
timed_text = ["live_events"]
# 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
@ -646,6 +660,16 @@ name = "wai_live_compose_vectors"
path = "src/bin/wai_live_compose_vectors.rs"
required-features = ["live_compose"]
[[bin]]
name = "wai_live_record_vectors"
path = "src/bin/wai_live_record_vectors.rs"
required-features = ["live_record"]
[[bin]]
name = "wai_timed_text_vectors"
path = "src/bin/wai_timed_text_vectors.rs"
required-features = ["timed_text"]
[[bin]]
name = "wai_world_meter"
path = "src/bin/wai_world_meter.rs"
@ -1276,6 +1300,16 @@ name = "live_compose"
path = "tests/live_compose.rs"
required-features = ["live_compose"]
[[test]]
name = "live_record"
path = "tests/live_record.rs"
required-features = ["live_record"]
[[test]]
name = "timed_text"
path = "tests/timed_text.rs"
required-features = ["timed_text"]
[[test]]
name = "world_replay"
path = "tests/world_replay.rs"

View file

@ -0,0 +1,53 @@
//! Conformance corpus for live recording (`live-conformance/recordings/`, extensions/live-recording.md §10).
//!
//! wai_live_record_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_live_record_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/live_record_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::path::Path;
use wai::live_record::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_record_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 vector 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

@ -0,0 +1,75 @@
//! Conformance corpus for timed text (`live-conformance/captions/`, extensions/timed-text.md §13).
//!
//! wai_timed_text_vectors gen <corpus-dir> write vectors.json and every expected output
//! (.vtt, .ttml, .608.hex), from fixed seeds
//! wai_timed_text_vectors verify <corpus-dir> every file regenerates byte for byte, no other
//! file but README.md is there, and every case
//! gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/timed_text_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::collections::BTreeMap;
use std::path::Path;
use wai::timed_text::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)) => {
if let Err(e) = std::fs::create_dir_all(d) {
eprintln!("create {d}: {e}");
std::process::exit(1);
}
for (name, body) in corpus::generate() {
let p = Path::new(d).join(&name);
if let Err(e) = std::fs::write(&p, body) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
}
println!("wrote {d}");
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_timed_text_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let mut files = BTreeMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("read {}: {e}", dir.display());
return 1;
}
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name == "README.md" {
continue;
}
match std::fs::read_to_string(e.path()) {
Ok(s) => {
files.insert(name, s);
}
Err(err) => {
eprintln!("read {name}: {err}");
return 1;
}
}
}
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let same = files == generated;
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
let fails = corpus::verify(&files);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
(!same || !fails.is_empty()) as i32
}

View file

@ -18,26 +18,26 @@ use crate::output_tree::consistency_proof;
use serde_json::{json, Map, Value};
/// The rooms' `t0`: 2026-10-16T00:00:00Z, in microseconds and in seconds.
const T0_US: u64 = 1_792_108_800_000_000;
const T0_S: u64 = T0_US / 1_000_000;
const S: u64 = 1_000_000;
const MS: u64 = 1_000;
const ORIGIN: &str = "https://lessons.example";
pub(crate) const T0_US: u64 = 1_792_108_800_000_000;
pub(crate) const T0_S: u64 = T0_US / 1_000_000;
pub(crate) const S: u64 = 1_000_000;
pub(crate) const MS: u64 = 1_000;
pub(crate) const ORIGIN: &str = "https://lessons.example";
/// The evaluation instant of every vector: a day after `t0`.
const EVALUATED_AT: u64 = T0_S + 86_400;
pub(crate) const EVALUATED_AT: u64 = T0_S + 86_400;
const AUTHORITY: u8 = 0;
const RELAY: u8 = 1;
const DIRECTOR: u8 = 2;
const DEVICE: u8 = 3;
const PERFORMER: u8 = 4;
const VIEWER: u8 = 5;
const STORE: u8 = 6;
const GUARDIAN: u8 = 7;
const MINOR: u8 = 8;
const IMPOSTOR: u8 = 9;
const LOG_ROOT: u8 = 10;
const LOG_NEXT: u8 = 11;
pub(crate) const AUTHORITY: u8 = 0;
pub(crate) const RELAY: u8 = 1;
pub(crate) const DIRECTOR: u8 = 2;
pub(crate) const DEVICE: u8 = 3;
pub(crate) const PERFORMER: u8 = 4;
pub(crate) const VIEWER: u8 = 5;
pub(crate) const STORE: u8 = 6;
pub(crate) const GUARDIAN: u8 = 7;
pub(crate) const MINOR: u8 = 8;
pub(crate) const IMPOSTOR: u8 = 9;
pub(crate) const LOG_ROOT: u8 = 10;
pub(crate) const LOG_NEXT: u8 = 11;
const PRINCIPALS: [(&str, u8); 12] = [
("authority", AUTHORITY),
@ -54,15 +54,15 @@ const PRINCIPALS: [(&str, u8); 12] = [
("log_next", LOG_NEXT),
];
fn key(i: u8) -> SigningKey {
pub(crate) fn key(i: u8) -> SigningKey {
SigningKey::from_bytes(&sha256(&[b"wai:capture-take/corpus/key\x00", &[i]]))
}
fn pk(i: u8) -> [u8; 32] {
pub(crate) fn pk(i: u8) -> [u8; 32] {
key(i).verifying_key().to_bytes()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
pub(crate) fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
@ -78,7 +78,7 @@ fn bits(ks: &[u8]) -> [u8; 32] {
}
/// The bytes of chunk `i`: 48 to 111 bytes of SHA-256 output under a fixed label.
fn chunk_bytes(label: &[u8], i: usize) -> Vec<u8> {
pub(crate) fn chunk_bytes(label: &[u8], i: usize) -> Vec<u8> {
let len = 48 + (i * 37) % 64;
let mut out = Vec::with_capacity(len + 32);
let mut j = 0u8;
@ -96,22 +96,22 @@ fn role(name: &str, publish: u8, max_layer: u8, powers: u8, events: &[u8]) -> Ro
Role { name: name.into(), caps: Caps { publish, subscribe: true, max_layer, powers }, events: bits(events) }
}
fn director() -> Role {
pub(crate) fn director() -> Role {
use events::kind::*;
role("director", 1, 2, power::MODERATE | power::DIRECT | power::RECORD, &[META, CHAT, REACTION, MARKER, CONTROL, CAPTION, DECISION])
}
fn performer() -> Role {
pub(crate) fn performer() -> Role {
use events::kind::*;
role("performer", 1 | 2, 2, 0, &[CHAT, REACTION, MARKER, TAKE_COMMIT])
}
fn audience() -> Role {
pub(crate) fn audience() -> Role {
use events::kind::*;
role("audience", 0, 1, 0, &[CHAT, REACTION])
}
fn kinds_of(b: &[u8; 32]) -> Vec<u8> {
pub(crate) fn kinds_of(b: &[u8; 32]) -> Vec<u8> {
(0..=255u8).filter(|k| b[(k / 8) as usize] & (1 << (k % 8)) != 0).collect()
}

View file

@ -213,6 +213,8 @@ 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";
pub const CAP_LIVE_RECORDING: &str = "wai.live.recording";
pub const CAP_TEXT_TIMED: &str = "wai.text.timed";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -294,6 +296,8 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(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_LIVE_RECORDING, S_INTERACTIVE, None, "2026-10-17"),
(CAP_TEXT_TIMED, 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"),
@ -340,7 +344,8 @@ 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_COMPOSE,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING,
CAP_TEXT_TIMED,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -482,6 +487,8 @@ 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 LIVE_RECORDING: &str = "extensions/live-recording.md";
const TIMED_TEXT: &str = "extensions/timed-text.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -490,8 +497,9 @@ 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, of capture binding and
/// of live composition, 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, of
/// live composition, of live recording and of timed text, 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),
@ -547,6 +555,15 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
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),
wire("WRM1", WireKind::Magic, LIVE_RECORDING, "§4.1", WireStatus::Defined),
wire("WCL1", WireKind::Magic, LIVE_RECORDING, "§5.1", WireStatus::Defined),
wire("WTX1", WireKind::Magic, TIMED_TEXT, "§4.2", WireStatus::Defined),
wire("wai_origin", WireKind::Catalog, TIMED_TEXT, "§7.2", WireStatus::Defined),
wire("wai_model", WireKind::Catalog, TIMED_TEXT, "§7.2", WireStatus::Defined),
wire("wai_machine_generated", WireKind::Catalog, TIMED_TEXT, "§7.4", WireStatus::Defined),
wire("tag:transaction.science,2026:wai/model/", WireKind::Scheme, TIMED_TEXT, "§10.3", WireStatus::Defined),
wire("tag:transaction.science,2026:wai/timed-text", WireKind::Scheme, TIMED_TEXT, "§10.4", WireStatus::Defined),
wire("science.transaction.wai.timed-text", WireKind::DataId, TIMED_TEXT, "§10.5", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

View file

@ -414,6 +414,24 @@ pub mod capture_take;
/// cbindgen:ignore
#[cfg(feature = "live_compose")]
pub mod live_compose;
// Live recording (extensions/live-recording.md): `wai.live.recording` — a finished room session
// as one WAI package whose components are the room, its events, each participant's take and
// the composite, under a signed recording manifest, and provable clips: a span of a take, or an
// aligned span of several, carrying range proofs against the take's commitments, checkable
// without the take. Rust-only, like capture_take: its constants must not become macros in
// include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "live_record")]
pub mod live_record;
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT
// documents and segments, IMSC1 documents, the MoQ caption timelines, CTA-608 and HLS,
// with the machine-generation disclosure carried through each. Rust-only, like
// live_events: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "timed_text")]
pub mod timed_text;
// 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,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -193,6 +193,98 @@ pub fn verify_proof(leaf: [u8; 32], index: u64, path: &[[u8; 32]], c: &OutputCom
sn == 0 && r == root
}
/// The audit path of leaf `index` among the leaf hashes `d` (RFC 6962 §2.1.1, RFC 9162
/// §2.1.3.1), from the leaf up: [`proof`] over leaves of any domain. `None` when `index`
/// is not below `d.len()`.
pub fn audit_path(index: usize, d: &[[u8; 32]]) -> Option<Vec<[u8; 32]>> {
if index >= d.len() {
return None;
}
let mut out = Vec::new();
path(index, d, &mut out);
Some(out)
}
/// The largest power of two below `n` (`n ≥ 2`), on `u64`.
fn split64(n: u64) -> u64 {
1u64 << (63 - (n - 1).leading_zeros())
}
fn range_nodes(a: usize, b: usize, d: &[[u8; 32]], out: &mut Vec<[u8; 32]>) {
let n = d.len();
if a == 0 && b == n {
return;
}
let k = split(n);
if b <= k {
range_nodes(a, b, &d[..k], out);
out.push(mth(&d[k..]));
} else if a >= k {
range_nodes(a - k, b - k, &d[k..], out);
out.push(mth(&d[..k]));
} else {
range_nodes(a, k, &d[..k], out);
range_nodes(0, b - k, &d[k..], out);
}
}
/// The range proof that leaves `a` to `b` − 1 of the leaf hashes `d` are those of the
/// tree over `d` (extensions/live-recording.md §5.3): RFC 6962 §2.1.1's `PATH` taken over
/// a run of leaves instead of one. With `n` = `d.len()` and `k` the largest power of two
/// below `n`, `RANGE(a, b, D[n])` is empty when `a` is 0 and `b` is `n`, and otherwise
/// `RANGE(a, b, D[0:k]) : MTH(D[k:n])` when `b ≤ k`, `RANGE(a − k, b − k, D[k:n]) :
/// MTH(D[0:k])` when `a ≥ k`, and `RANGE(a, k, D[0:k]) : RANGE(0, b − k, D[k:n])`
/// otherwise. A run of one leaf is never split, so `range_proof(i, i + 1, d)` is leaf
/// `i`'s audit path, node for node. `None` unless `a < b ≤ n`.
pub fn range_proof(a: usize, b: usize, d: &[[u8; 32]]) -> Option<Vec<[u8; 32]>> {
if a >= b || b > d.len() {
return None;
}
let mut out = Vec::new();
range_nodes(a, b, d, &mut out);
Some(out)
}
/// The root a range proof walks to, consuming `proof` from the front in the order
/// [`range_proof`] writes it; `None` when it runs out.
fn range_root(a: u64, b: u64, n: u64, leaves: &[[u8; 32]], proof: &mut std::slice::Iter<'_, [u8; 32]>) -> Option<[u8; 32]> {
if a == 0 && b == n {
return Some(mth(leaves));
}
let k = split64(n);
if b <= k {
let x = range_root(a, b, k, leaves, proof)?;
Some(node(&x, proof.next()?))
} else if a >= k {
let x = range_root(a - k, b - k, n - k, leaves, proof)?;
Some(node(proof.next()?, &x))
} else {
let at = (k - a) as usize;
let x = range_root(a, k, k, &leaves[..at], proof)?;
let y = range_root(0, b - k, n - k, &leaves[at..], proof)?;
Some(node(&x, &y))
}
}
/// Whether `proof` shows that the leaf hashes `leaves` are leaves `first` onward of the
/// tree `c` commits to, under its leaf count: the tree's root rebuilt by the recursion of
/// [`range_proof`], every node of the proof used once, in order, and none left over
/// (extensions/live-recording.md §5.3). For one leaf it gives what [`verify_proof`], RFC
/// 9162 §2.1.3.2's check, gives for that leaf's audit path. The leaf count is the
/// commitment's, never the proof holder's, as for an audit path.
pub fn verify_range(first: u64, leaves: &[[u8; 32]], proof: &[[u8; 32]], c: &OutputCommitment) -> bool {
let n = c.leaf_count;
let Some(b) = first.checked_add(leaves.len() as u64) else { return false };
if leaves.is_empty() || b > n {
return false;
}
let mut it = proof.iter();
match range_root(first, b, n, leaves, &mut it) {
Some(r) => it.next().is_none() && r == c.root,
None => false,
}
}
fn subproof(m: usize, d: &[[u8; 32]], b: bool, out: &mut Vec<[u8; 32]>) {
let n = d.len();
if m == n {
@ -266,6 +358,7 @@ pub fn verify_consistency(first: u64, second: u64, first_root: &[u8; 32], second
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
@ -384,6 +477,85 @@ mod tests {
}
}
/// The range proof of every run of leaves of trees of 1 to 33 leaves verifies; a run
/// of one leaf is its RFC 6962 audit path, node for node, and RFC 9162 §2.1.3.2's check
/// (`verify_proof`) gives the same answer over it as the range check does, whatever is
/// changed; and a flipped bit, a node added or dropped, a leaf replaced, the run moved,
/// or the next tree's root under the next size is refused.
#[test]
fn range_proofs_generalise_the_audit_path() {
let leaves: Vec<[u8; 32]> = (0..34u8).map(|i| sha256(&[b'r', i])).collect();
for n in 1..=33usize {
let d = &leaves[..n];
let c = OutputCommitment { root: tree_hash(d), leaf_count: n as u64 };
let next = OutputCommitment { root: tree_hash(&leaves[..n + 1]), leaf_count: n as u64 + 1 };
for a in 0..n {
let path = audit_path(a, d).unwrap();
assert_eq!(range_proof(a, a + 1, d).unwrap(), path, "n = {n}, a = {a}");
assert!(verify_proof(d[a], a as u64, &path, &c));
assert!(verify_range(a as u64, &d[a..a + 1], &path, &c));
// The two checks agree on every single-leaf change.
let mut bad = path.clone();
if let Some(x) = bad.first_mut() {
x[3] ^= 0x40;
assert_eq!(verify_proof(d[a], a as u64, &bad, &c), verify_range(a as u64, &d[a..a + 1], &bad, &c));
assert!(!verify_range(a as u64, &d[a..a + 1], &bad, &c));
}
let other = sha256(b"not a leaf");
assert_eq!(verify_proof(other, a as u64, &path, &c), verify_range(a as u64, &[other], &path, &c));
for b in a + 1..=n {
let p = range_proof(a, b, d).unwrap();
let run = &d[a..b];
assert!(verify_range(a as u64, run, &p, &c), "n = {n}, [{a}, {b})");
assert!(p.len() <= 2 * (usize::BITS - (n - 1).leading_zeros()) as usize, "n = {n}, [{a}, {b}): {} nodes", p.len());
assert!(!verify_range(a as u64, run, &p, &next), "n = {n}, [{a}, {b}), next tree");
let mut extra = p.clone();
extra.push([0; 32]);
assert!(!verify_range(a as u64, run, &extra, &c), "a node left over");
if !p.is_empty() {
let mut flipped = p.clone();
let last = flipped.len() - 1;
flipped[last][0] ^= 1;
assert!(!verify_range(a as u64, run, &flipped, &c), "n = {n}, [{a}, {b}), bit");
assert!(!verify_range(a as u64, run, &p[..last], &c), "a node dropped");
}
let mut swapped = run.to_vec();
swapped[run.len() / 2][31] ^= 2;
assert!(!verify_range(a as u64, &swapped, &p, &c), "n = {n}, [{a}, {b}), leaf");
if b < n {
assert!(!verify_range(a as u64 + 1, run, &p, &c), "n = {n}, [{a}, {b}), moved");
}
}
}
assert_eq!(range_proof(0, 0, d), None);
assert_eq!(range_proof(0, n + 1, d), None);
assert!(!verify_range(0, &[], &[], &c));
assert!(!verify_range(n as u64, &[d[0]], &[], &c));
assert!(!verify_range(u64::MAX, &[d[0]], &[], &c));
// The whole tree needs no node: its root is the leaves' tree hash.
assert!(range_proof(0, n, d).unwrap().is_empty());
}
}
/// The nodes of a range proof are nodes some audit path of a leaf in the range holds:
/// it proves no more than the audit paths of its leaves do, with the nodes they share
/// written once and those the run's own leaves give left out.
#[test]
fn a_range_proof_is_drawn_from_its_leaves_audit_paths() {
let leaves: Vec<[u8; 32]> = (0..21u8).map(|i| sha256(&[b'p', i])).collect();
for n in 1..=21usize {
let d = &leaves[..n];
for a in 0..n {
for b in a + 1..=n {
let union: BTreeSet<[u8; 32]> = (a..b).flat_map(|i| audit_path(i, d).unwrap()).collect();
for x in range_proof(a, b, d).unwrap() {
assert!(union.contains(&x), "n = {n}, [{a}, {b})");
}
}
}
}
}
#[test]
fn duplicate_presentation_is_refused() {
let mut p = ps(4);

1632
wai/wai-rs/src/timed_text.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,796 @@
//! CTA-608 on field 1, data channel 1 (CC1): pop-on and roll-up (extensions/timed-text.md §8).
//!
//! The encoder turns cues into byte pairs on the field-1 slot clock: one pair per slot,
//! odd parity on every byte, every two-byte code sent twice in consecutive slots, and a
//! standard character before each extended one, which the extended code backspaces over.
//! The decoder reads those pairs back, for round trips: it emulates the two caption
//! memories, the cursor and the attributes, and reports what is displayed.
use super::{check_document, lines, sorted, Cue, Reason};
use std::collections::BTreeMap;
/// How captions are presented (§8.6, §8.7).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
PopOn,
/// Roll-up with a window of 2, 3 or 4 rows.
RollUp(u8),
}
impl Mode {
pub fn name(self) -> String {
match self {
Mode::PopOn => "pop-on".into(),
Mode::RollUp(d) => format!("roll-up-{d}"),
}
}
pub fn from_name(s: &str) -> Option<Mode> {
match s {
"pop-on" => Some(Mode::PopOn),
"roll-up-2" => Some(Mode::RollUp(2)),
"roll-up-3" => Some(Mode::RollUp(3)),
"roll-up-4" => Some(Mode::RollUp(4)),
_ => None,
}
}
/// The most rows one cue may have.
fn max_rows(self) -> usize {
match self {
Mode::PopOn => 4,
Mode::RollUp(d) => d as usize,
}
}
}
/// The rate of the field-1 slot clock, `num / den` slots a second (§8.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rate {
pub num: u64,
pub den: u64,
}
/// The slot clock of line 21 and of 608 data carried with video at 30000/1001 frames
/// a second.
pub const NTSC: Rate = Rate { num: 30000, den: 1001 };
impl Rate {
/// The first slot at or after media time `ms` (§8.2).
pub fn slot(self, ms: u64) -> u64 {
let n = ms as u128 * self.num as u128;
let d = 1000u128 * self.den as u128;
n.div_ceil(d) as u64
}
fn ok(self) -> bool {
self.num > 0 && self.den > 0 && self.num <= 1_000_000 && self.den <= 1_000_000
}
}
/// The characters of 0x11 0x30 to 0x11 0x3F (§8.4): 0x39 is the transparent space,
/// written U+00A0.
pub const SPECIAL: [char; 16] =
['®', '°', '½', '¿', '™', '¢', '£', '♪', 'à', '\u{a0}', 'è', 'â', 'ê', 'î', 'ô', 'û'];
/// The characters of 0x12 0x20 to 0x12 0x3F (§8.4); 0x12 0x29 is not assigned here.
pub const EXT12: [Option<char>; 32] = [
Some('Á'), Some('É'), Some('Ó'), Some('Ú'), Some('Ü'), Some('ü'), Some('‘'), Some('¡'),
Some('*'), None, Some('—'), Some('©'), Some('℠'), Some('•'), Some('“'), Some('”'),
Some('À'), Some('Â'), Some('Ç'), Some('È'), Some('Ê'), Some('Ë'), Some('ë'), Some('Î'),
Some('Ï'), Some('ï'), Some('Ô'), Some('Ù'), Some('ù'), Some('Û'), Some('«'), Some('»'),
];
/// The characters of 0x13 0x20 to 0x13 0x3F (§8.4).
pub const EXT13: [Option<char>; 32] = [
Some('Ã'), Some('ã'), Some('Í'), Some('Ì'), Some('ì'), Some('Ò'), Some('ò'), Some('Õ'),
Some('õ'), Some('{'), Some('}'), Some('\\'), Some('^'), Some('_'), Some('|'), Some('~'),
Some('Ä'), Some('ä'), Some('Ö'), Some('ö'), Some('ß'), Some('¥'), Some('¤'), Some('¦'),
Some('Å'), Some('å'), Some('Ø'), Some('ø'), Some('┌'), Some('┐'), Some('└'), Some('┘'),
];
/// The characters this mapping writes as another (§8.4).
pub const SUBSTITUTIONS: [(char, char); 4] = [('\u{2019}', '\''), ('\u{2013}', '-'), ('\u{2010}', '-'), ('\u{2011}', '-')];
/// The standard character of the basic set at `b` (§8.4).
pub fn basic_char(b: u8) -> Option<char> {
Some(match b {
0x2A => 'á',
0x5C => 'é',
0x5E => 'í',
0x5F => 'ó',
0x60 => 'ú',
0x7B => 'ç',
0x7C => '÷',
0x7D => 'Ñ',
0x7E => 'ñ',
0x20..=0x7A => b as char,
_ => return None,
})
}
/// The byte of `c` in the basic set (§8.4).
pub fn basic_byte(c: char) -> Option<u8> {
(0x20u8..=0x7E).find(|&b| basic_char(b) == Some(c))
}
/// The standard character sent before an extended one (§8.4).
pub fn fallback(c: char) -> u8 {
match c {
'Á' | 'À' | 'Â' | 'Ã' | 'Ä' | 'Å' => b'A',
'É' | 'È' | 'Ê' | 'Ë' => b'E',
'Í' | 'Ì' | 'Î' | 'Ï' => b'I',
'Ó' | 'Ò' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => b'O',
'Ú' | 'Ù' | 'Û' | 'Ü' => b'U',
'Ç' => b'C',
'ã' | 'ä' | 'å' => b'a',
'ë' => b'e',
'ì' | 'ï' => b'i',
'ò' | 'õ' | 'ö' | 'ø' => b'o',
'ù' | 'ü' => b'u',
'ß' => b's',
'“' | '”' | '«' | '»' => b'"',
'‘' => b'\'',
'¡' => b'!',
_ => b' ',
}
}
/// How one character is sent (§8.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Char {
Basic(u8),
Special(u8),
Extended(u8, u8, u8),
}
/// The character `c` is written as, after substitution, and how it is sent.
fn map_char(c: char) -> Option<(char, Char)> {
let c = SUBSTITUTIONS.iter().find(|(f, _)| *f == c).map_or(c, |(_, t)| *t);
if let Some(b) = basic_byte(c) {
return Some((c, Char::Basic(b)));
}
if let Some(i) = SPECIAL.iter().position(|&s| s == c) {
return Some((c, Char::Special(0x30 + i as u8)));
}
for (first, table) in [(0x12u8, &EXT12), (0x13, &EXT13)] {
if let Some(i) = table.iter().position(|&s| s == Some(c)) {
return Some((c, Char::Extended(first, 0x20 + i as u8, fallback(c))));
}
}
None
}
/// The text `c` is decoded as when the encoder writes `c` (§8.4).
pub fn written_as(c: char) -> Option<char> {
map_char(c).map(|(w, _)| w)
}
/// The first byte of a preamble address code for rows 1 to 15 (§8.5).
const ROW_FIRST: [u8; 15] = [0x11, 0x11, 0x12, 0x12, 0x15, 0x15, 0x16, 0x16, 0x17, 0x17, 0x10, 0x13, 0x13, 0x14, 0x14];
fn row_base(row: u8) -> u8 {
if matches!(row, 2 | 4 | 6 | 8 | 10 | 13 | 15) { 0x60 } else { 0x40 }
}
/// The preamble address code of `row` at indent `4 * k`, white.
pub fn pac_indent(row: u8, k: u8) -> [u8; 2] {
[ROW_FIRST[row as usize - 1], row_base(row) + 0x10 + 2 * k]
}
/// The preamble address code of `row`, white italics, at column 0.
pub fn pac_italic(row: u8) -> [u8; 2] {
[ROW_FIRST[row as usize - 1], row_base(row) + 0x0E]
}
pub const RCL: [u8; 2] = [0x14, 0x20];
pub const EDM: [u8; 2] = [0x14, 0x2C];
pub const CR: [u8; 2] = [0x14, 0x2D];
pub const ENM: [u8; 2] = [0x14, 0x2E];
pub const EOC: [u8; 2] = [0x14, 0x2F];
pub const MRC_WHITE: [u8; 2] = [0x11, 0x20];
pub const MRC_ITALIC: [u8; 2] = [0x11, 0x2E];
pub fn ru(d: u8) -> [u8; 2] {
[0x14, 0x23 + d]
}
pub fn to(k: u8) -> [u8; 2] {
[0x17, 0x20 + k]
}
/// A byte with odd parity in bit 7 (§8.3).
pub fn parity(b: u8) -> u8 {
let b = b & 0x7F;
if b.count_ones().is_multiple_of(2) { b | 0x80 } else { b }
}
/// One row a cue occupies (§8.5), and one row a decoder displays.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Row {
/// 1 to 15.
pub row: u8,
/// The column of its first character, 0 to 31.
pub col: u8,
/// The text, after substitution.
pub text: String,
/// Italic ranges over `text`, in code points.
pub italic: Vec<(u32, u32)>,
}
/// The rows of a cue in a mode (§8.5), checked in order: rows, characters, width, style.
pub fn layout(mode: Mode, c: &Cue) -> Result<Vec<Row>, Reason> {
let ls = lines(c);
if ls.len() > mode.max_rows() {
return Err(Reason::TooManyRows);
}
let mut rows = Vec::new();
let n = ls.len() as u8;
for (k, line) in ls.iter().enumerate() {
let mut text = String::new();
let mut flags = Vec::new();
for (it, s) in line {
for ch in s.chars() {
text.push(written_as(ch).ok_or(Reason::CharNotIn608)?);
flags.push(*it);
}
}
rows.push((k as u8, text, flags));
}
let mut out = Vec::new();
for (k, text, flags) in rows {
let width = flags.len();
if width > 32 {
return Err(Reason::RowTooLong);
}
let cps: Vec<char> = text.chars().collect();
let mut italic: Vec<(u32, u32)> = Vec::new();
for (i, &f) in flags.iter().enumerate() {
if !f {
continue;
}
match italic.last_mut() {
Some((s, l)) if *s + *l == i as u32 => *l += 1,
_ => italic.push((i as u32, 1)),
}
}
for &(s, l) in &italic {
let (s, e) = (s as usize, (s + l) as usize);
if (s > 0 && cps[s - 1] != ' ') || (e < width && cps[e] != ' ') {
return Err(Reason::StyleNotExpressible);
}
}
let row = match (mode, c.anchor.top()) {
(Mode::PopOn, true) => 1 + k,
(Mode::PopOn, false) => 16 - n + k,
(Mode::RollUp(d), true) => d,
(Mode::RollUp(_), false) => 15,
};
let col = match c.anchor.align() {
"left" => 0,
"right" => 32 - width,
_ => (32 - width) / 2,
} as u8;
out.push(Row { row, col, text, italic });
}
Ok(out)
}
/// What the encoder sends: a byte of the basic set, or a two-byte code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Code {
Byte(u8),
Two([u8; 2]),
}
fn position(out: &mut Vec<Code>, row: u8, col: u8) {
out.push(Code::Two(pac_indent(row, col / 4)));
if !col.is_multiple_of(4) {
out.push(Code::Two(to(col % 4)));
}
}
/// The codes that write one row (§8.5).
fn row_codes(r: &Row) -> Vec<Code> {
let cps: Vec<char> = r.text.chars().collect();
let mut flags = vec![false; cps.len()];
for &(s, l) in &r.italic {
for f in flags.iter_mut().skip(s as usize).take(l as usize) {
*f = true;
}
}
let mut out = Vec::new();
match (flags.first() == Some(&true), r.col) {
(true, 0) => out.push(Code::Two(pac_italic(r.row))),
(true, c) => {
position(&mut out, r.row, c - 1);
out.push(Code::Two(MRC_ITALIC));
}
(false, c) => position(&mut out, r.row, c),
}
for (i, &ch) in cps.iter().enumerate() {
if ch == ' ' && !flags[i] && flags.get(i + 1) == Some(&true) {
out.push(Code::Two(MRC_ITALIC));
} else if ch == ' ' && !flags[i] && i > 0 && flags[i - 1] {
out.push(Code::Two(MRC_WHITE));
} else {
match map_char(ch).expect("laid out").1 {
Char::Basic(b) => out.push(Code::Byte(b)),
Char::Special(b) => out.push(Code::Two([0x11, b])),
Char::Extended(f, b, fb) => {
out.push(Code::Byte(fb));
out.push(Code::Two([f, b]));
}
}
}
}
out
}
/// A unit of the schedule: one pair, or a two-byte code sent in two consecutive slots.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Unit {
Pair([u8; 2]),
Doubled([u8; 2]),
}
impl Unit {
fn slots(self) -> u64 {
match self {
Unit::Pair(_) => 1,
Unit::Doubled(_) => 2,
}
}
}
/// Pack codes into units (§8.3): standard bytes two to a pair in order, a lone one
/// before a two-byte code or at the end padded with 0x00.
fn pack(codes: &[Code]) -> Vec<Unit> {
let mut out = Vec::new();
let mut pending: Option<u8> = None;
for &c in codes {
match c {
Code::Byte(b) => match pending.take() {
Some(p) => out.push(Unit::Pair([p, b])),
None => pending = Some(b),
},
Code::Two(x) => {
if let Some(p) = pending.take() {
out.push(Unit::Pair([p, 0]));
}
out.push(Unit::Doubled(x));
}
}
}
if let Some(p) = pending {
out.push(Unit::Pair([p, 0]));
}
out
}
fn put(slots: &mut BTreeMap<u64, [u8; 2]>, at: u64, u: Unit) {
let x = match u {
Unit::Pair(x) | Unit::Doubled(x) => [parity(x[0]), parity(x[1])],
};
for k in 0..u.slots() {
slots.insert(at + k, x);
}
}
/// Encode cues in `lang` for `mode` on the slot clock `rate` (§8): the slots that carry
/// a pair, with the pair, parity applied; every other slot carries 0x80 0x80.
pub fn encode(mode: Mode, rate: Rate, lang: &str, cues: &[Cue]) -> Result<Vec<(u64, [u8; 2])>, Reason> {
check_document(lang, cues)?;
if !rate.ok() || !matches!(mode, Mode::PopOn | Mode::RollUp(2..=4)) {
return Err(Reason::ParameterMalformed);
}
let cues = sorted(cues);
let mut laid = Vec::new();
for c in &cues {
laid.push(layout(mode, c)?);
}
if cues.windows(2).any(|w| w[1].start < w[0].end) {
return Err(Reason::CuesOverlap);
}
if matches!(mode, Mode::RollUp(_)) && cues.windows(2).any(|w| w[0].anchor.top() != w[1].anchor.top()) {
return Err(Reason::StyleNotExpressible);
}
let s: Vec<u64> = cues.iter().map(|c| rate.slot(c.start)).collect();
let e: Vec<u64> = cues.iter().map(|c| rate.slot(c.end)).collect();
let n = cues.len();
let edm: Vec<bool> = (0..n).map(|i| i + 1 == n || s[i + 1] >= e[i] + 2).collect();
let mut slots: BTreeMap<u64, [u8; 2]> = BTreeMap::new();
match mode {
Mode::PopOn => {
for i in 0..n {
if e[i] < s[i] + 2 {
return Err(Reason::TooDense);
}
put(&mut slots, s[i], Unit::Doubled(EOC));
if edm[i] {
put(&mut slots, e[i], Unit::Doubled(EDM));
}
}
for i in 0..n {
let mut codes = vec![Code::Two(RCL), Code::Two(ENM)];
for r in &laid[i] {
codes.extend(row_codes(r));
}
let lb = if i == 0 { 0 } else { s[i - 1] + 2 };
let mut cursor = s[i];
for u in pack(&codes).into_iter().rev() {
let need = u.slots();
let mut q = cursor.checked_sub(need).ok_or(Reason::TooDense)?;
loop {
if q < lb {
return Err(Reason::TooDense);
}
if (q..q + need).all(|k| !slots.contains_key(&k)) {
break;
}
q = q.checked_sub(1).ok_or(Reason::TooDense)?;
}
put(&mut slots, q, u);
cursor = q;
}
}
}
Mode::RollUp(d) => {
let mut prev_end = 0u64;
let mut base_has_text = false;
for i in 0..n {
if s[i] < prev_end {
return Err(Reason::TooDense);
}
let mut codes = Vec::new();
if i == 0 {
codes.push(Code::Two(ru(d)));
}
for r in &laid[i] {
if base_has_text {
codes.push(Code::Two(CR));
}
codes.extend(row_codes(r));
base_has_text = true;
}
let mut at = s[i];
for u in pack(&codes) {
put(&mut slots, at, u);
at += u.slots();
}
let limit = if edm[i] { e[i] } else { s[i + 1] };
if at > limit {
return Err(Reason::TooDense);
}
if edm[i] {
put(&mut slots, e[i], Unit::Doubled(EDM));
base_has_text = false;
prev_end = e[i] + 2;
} else {
prev_end = at;
}
}
}
}
Ok(slots.into_iter().collect())
}
/// The hexadecimal form of encoded pairs (§8.3): one line per slot that carries a pair,
/// its slot in decimal, a space, and the pair as four lowercase hexadecimal digits.
pub fn to_hex(pairs: &[(u64, [u8; 2])]) -> String {
pairs.iter().map(|(s, p)| format!("{s} {:02x}{:02x}\n", p[0], p[1])).collect()
}
/// Read the hexadecimal form (§8.3), slots strictly ascending.
pub fn from_hex(s: &str) -> Result<Vec<(u64, [u8; 2])>, Reason> {
let m = Reason::CcMalformed;
let body = s.strip_suffix('\n').ok_or(m)?;
let mut out: Vec<(u64, [u8; 2])> = Vec::new();
for line in body.split('\n') {
let (slot, pair) = line.split_once(' ').ok_or(m)?;
if slot.is_empty() || !slot.bytes().all(|c| c.is_ascii_digit()) || (slot.len() > 1 && slot.starts_with('0')) {
return Err(m);
}
if pair.len() != 4 || !pair.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
return Err(m);
}
let slot: u64 = slot.parse().map_err(|_| m)?;
let b = [u8::from_str_radix(&pair[..2], 16).map_err(|_| m)?, u8::from_str_radix(&pair[2..], 16).map_err(|_| m)?];
if out.last().is_some_and(|(p, _)| *p >= slot) {
return Err(m);
}
out.push((slot, b));
}
Ok(out)
}
/// What a decoder reports (§8.8).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
/// An End of Caption: what is displayed from `slot`.
Show { slot: u64, rows: Vec<Row> },
/// A roll-up line, from the slot of its preamble address code, committed by a
/// carriage return, an erase or the end of the stream.
Line { slot: u64, row: Row },
/// An Erase Displayed Memory.
Erase { slot: u64 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Cell {
ch: char,
italic: bool,
mrc: bool,
}
type Memory = [[Option<Cell>; 32]; 15];
fn read_row(row: u8, cells: &[Option<Cell>; 32]) -> Option<Row> {
let real = |k: usize| cells[k].is_some_and(|c| !c.mrc);
let i = (0..32).find(|&k| real(k))?;
let j = (0..32).rev().find(|&k| real(k))?;
let mut text = String::new();
let mut flags = Vec::new();
let mut space = Vec::new();
for c in &cells[i..=j] {
let ch = match c {
Some(c) if !c.mrc => c.ch,
_ => ' ',
};
text.push(ch);
flags.push(c.is_some_and(|c| !c.mrc && c.italic && c.ch != ' '));
space.push(ch == ' ');
}
let len = flags.len();
let inside = |k: usize| flags[k] || (space[k] && k > 0 && k + 1 < len && flags[k - 1] && flags[k + 1]);
let mut italic: Vec<(u32, u32)> = Vec::new();
for k in 0..len {
if inside(k) {
match italic.last_mut() {
Some((s, l)) if *s + *l == k as u32 => *l += 1,
_ => italic.push((k as u32, 1)),
}
}
}
Some(Row { row, col: i as u8, text, italic })
}
fn snapshot(mem: &Memory) -> Vec<Row> {
(1..=15u8).filter_map(|r| read_row(r, &mem[r as usize - 1])).collect()
}
/// Decode pairs (§8.8): the events a decoder of channel CC1 reports, for round trips of
/// what [`encode`] writes. A pair this encoder does not write is `cc_malformed`.
pub fn decode(pairs: &[(u64, [u8; 2])]) -> Result<Vec<Event>, Reason> {
let m = Reason::CcMalformed;
#[derive(PartialEq)]
enum St {
Unset,
Pop,
Roll(u8),
}
let mut disp: Memory = [[None; 32]; 15];
let mut nond: Memory = [[None; 32]; 15];
let mut st = St::Unset;
let (mut row, mut col, mut italic, mut base) = (15u8, 0usize, false, 15u8);
let mut line_slot: Option<u64> = None;
let mut last: Option<([u8; 2], u64)> = None;
let mut prev: Option<u64> = None;
let mut out = Vec::new();
let commit = |disp: &Memory, base: u8, line_slot: &mut Option<u64>, out: &mut Vec<Event>| {
if let (Some(r), Some(s)) = (read_row(base, &disp[base as usize - 1]), line_slot.take()) {
out.push(Event::Line { slot: s, row: r });
}
};
for &(slot, pair) in pairs {
if prev.is_some_and(|p| slot <= p) {
return Err(m);
}
prev = Some(slot);
if pair.iter().any(|b| b.count_ones() % 2 == 0) {
return Err(m);
}
let (b1, b2) = (pair[0] & 0x7F, pair[1] & 0x7F);
let write = |ch: char, mrc: bool, italic: bool, col: &mut usize, disp: &mut Memory, nond: &mut Memory| -> Result<(), Reason> {
let (mem, r) = match st {
St::Unset => return Err(m),
St::Pop => (nond, row),
St::Roll(_) => (disp, base),
};
mem[r as usize - 1][*col] = Some(Cell { ch, italic, mrc });
if *col < 31 {
*col += 1;
}
Ok(())
};
if (0x10..=0x1F).contains(&b1) {
if last.is_some_and(|(p, s)| p == [b1, b2] && s + 1 == slot) {
last = None;
continue;
}
last = Some(([b1, b2], slot));
match (b1, b2) {
(_, 0x40..=0x7F) => {
let r = (1..=15u8).find(|&r| ROW_FIRST[r as usize - 1] == b1 && row_base(r) == b2 & 0x60).ok_or(m)?;
let attr = b2 & 0x1F;
let (c, it) = match attr {
0x00 => (0, false),
0x0E => (0, true),
0x10..=0x1E if attr % 2 == 0 => ((attr as usize - 0x10) * 2, false),
_ => return Err(m),
};
row = r;
col = c;
italic = it;
if let St::Roll(_) = st {
base = r;
if line_slot.is_none() {
line_slot = Some(slot);
}
}
}
(0x14, 0x20) => st = St::Pop,
(0x14, 0x25..=0x27) => {
if st == St::Pop {
disp = [[None; 32]; 15];
nond = [[None; 32]; 15];
}
st = St::Roll(b2 - 0x23);
base = 15;
col = 0;
}
(0x14, 0x2C) => {
if let St::Roll(_) = st {
commit(&disp, base, &mut line_slot, &mut out);
}
line_slot = None;
disp = [[None; 32]; 15];
out.push(Event::Erase { slot });
}
(0x14, 0x2D) => {
let St::Roll(d) = st else { return Err(m) };
commit(&disp, base, &mut line_slot, &mut out);
let top = base as usize - d as usize;
for r in top..base as usize - 1 {
disp[r] = disp[r + 1];
}
disp[base as usize - 1] = [None; 32];
col = 0;
}
(0x14, 0x2E) => nond = [[None; 32]; 15],
(0x14, 0x2F) => {
std::mem::swap(&mut disp, &mut nond);
st = St::Pop;
out.push(Event::Show { slot, rows: snapshot(&disp) });
}
(0x17, 0x21..=0x23) => col = (col + (b2 - 0x20) as usize).min(31),
(0x11, 0x20) | (0x11, 0x2E) => {
italic = b2 == 0x2E;
write(' ', true, italic, &mut col, &mut disp, &mut nond)?;
}
(0x11, 0x30..=0x3F) => write(SPECIAL[(b2 - 0x30) as usize], false, italic, &mut col, &mut disp, &mut nond)?,
(0x12 | 0x13, 0x20..=0x3F) => {
let table = if b1 == 0x12 { &EXT12 } else { &EXT13 };
let ch = table[(b2 - 0x20) as usize].ok_or(m)?;
col = col.saturating_sub(1);
write(ch, false, italic, &mut col, &mut disp, &mut nond)?;
}
_ => return Err(m),
}
} else {
last = None;
for b in [b1, b2] {
if b == 0 {
continue;
}
let ch = basic_char(b).ok_or(m)?;
write(ch, false, italic, &mut col, &mut disp, &mut nond)?;
}
}
}
if let St::Roll(_) = st {
commit(&disp, base, &mut line_slot, &mut out);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::super::{origin, Anchor};
use super::*;
fn cue(start: u64, end: u64, text: &str, anchor: Anchor, italic: Vec<(u32, u32)>) -> Cue {
Cue { start, end, lang: "en".into(), origin: origin::HUMAN, model: None, anchor, text: text.into(), italic }
}
#[test]
fn parity_and_the_well_known_codes() {
let hex = |x: [u8; 2]| format!("{:02x}{:02x}", parity(x[0]), parity(x[1]));
assert_eq!(hex(RCL), "9420");
assert_eq!(hex(EOC), "942f");
assert_eq!(hex(EDM), "942c");
assert_eq!(hex(ENM), "94ae");
assert_eq!(hex(CR), "94ad");
assert_eq!(hex(ru(2)), "9425");
assert_eq!(hex(pac_indent(15, 0)), "9470");
assert_eq!(hex(pac_indent(14, 0)), "94d0");
assert_eq!(hex(pac_indent(1, 0)), "91d0");
assert_eq!(hex(pac_italic(14)), "94ce");
assert_eq!(hex(pac_italic(15)), "946e");
}
#[test]
fn the_character_tables_are_one_to_one() {
let mut seen = std::collections::BTreeSet::new();
for b in 0x20..=0x7E {
if let Some(c) = basic_char(b) {
assert!(seen.insert(c), "{c:?}");
}
}
for c in SPECIAL.iter().chain(EXT12.iter().flatten()).chain(EXT13.iter().flatten()) {
assert!(seen.insert(*c), "{c:?} twice");
}
assert_eq!(basic_byte('*'), None, "'*' is not 0x2A, which is á");
assert_eq!(map_char('*').map(|x| x.1), Some(Char::Extended(0x12, 0x28, b' ')));
assert_eq!(written_as('\u{2019}'), Some('\''));
assert_eq!(written_as('€'), None);
}
#[test]
fn pop_on_round_trips() {
let cues = vec![
cue(1000, 3000, "Hello big world\nsecond", Anchor::BottomCenter, vec![(6, 9)]),
cue(3000, 4000, "Right ♪", Anchor::TopRight, vec![]),
cue(5000, 7000, "Él dijo «sí»", Anchor::BottomLeft, vec![(0, 2)]),
];
let pairs = encode(Mode::PopOn, NTSC, "en", &cues).unwrap();
let ev = decode(&pairs).unwrap();
let shows: Vec<&Event> = ev.iter().filter(|e| matches!(e, Event::Show { .. })).collect();
assert_eq!(shows.len(), 3);
if let Event::Show { slot, rows } = shows[0] {
assert_eq!(*slot, NTSC.slot(1000));
assert_eq!(rows, &layout(Mode::PopOn, &cues[0]).unwrap());
}
if let Event::Show { rows, .. } = shows[2] {
assert_eq!(rows, &layout(Mode::PopOn, &cues[2]).unwrap());
}
assert_eq!(ev.iter().filter(|e| matches!(e, Event::Erase { .. })).count(), 2, "no erase between back-to-back cues");
assert_eq!(from_hex(&to_hex(&pairs)).unwrap(), pairs);
}
#[test]
fn roll_up_round_trips() {
let cues = vec![
cue(1000, 4000, "one\ntwo", Anchor::BottomLeft, vec![]),
cue(4000, 6000, "three is longer", Anchor::BottomCenter, vec![(0, 5)]),
];
for d in 2..=4 {
let pairs = encode(Mode::RollUp(d), NTSC, "en", &cues).unwrap();
let lines: Vec<String> = decode(&pairs)
.unwrap()
.into_iter()
.filter_map(|e| match e {
Event::Line { row, .. } => Some(row.text),
_ => None,
})
.collect();
assert_eq!(lines, ["one", "two", "three is longer"]);
}
}
#[test]
fn what_608_cannot_hold_is_refused() {
let one = |t: &str, it: Vec<(u32, u32)>| vec![cue(1000, 3000, t, Anchor::BottomCenter, it)];
assert_eq!(encode(Mode::PopOn, NTSC, "en", &one("a\nb\nc\nd\ne", vec![])), Err(Reason::TooManyRows));
assert_eq!(encode(Mode::PopOn, NTSC, "en", &one(&"x".repeat(33), vec![])), Err(Reason::RowTooLong));
assert_eq!(encode(Mode::PopOn, NTSC, "en", &one("5 €", vec![])), Err(Reason::CharNotIn608));
assert_eq!(encode(Mode::PopOn, NTSC, "en", &one("unbelievable", vec![(2, 4)])), Err(Reason::StyleNotExpressible));
let two = vec![cue(0, 2000, "a", Anchor::BottomCenter, vec![]), cue(1000, 3000, "b", Anchor::BottomCenter, vec![])];
assert_eq!(encode(Mode::PopOn, NTSC, "en", &two), Err(Reason::CuesOverlap));
let dense = vec![cue(1000, 1100, &"x".repeat(32), Anchor::BottomCenter, vec![]), cue(1100, 1300, &"y".repeat(32), Anchor::BottomCenter, vec![])];
assert_eq!(encode(Mode::PopOn, NTSC, "en", &dense), Err(Reason::TooDense));
let mut bad = encode(Mode::PopOn, NTSC, "en", &one("ok", vec![])).unwrap();
bad[0].1[1] ^= 0x01;
assert_eq!(decode(&bad), Err(Reason::CcMalformed));
}
}

File diff suppressed because it is too large Load diff

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, 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"),
("live-conformance", "live events, rooms, takes, compositions, recordings and captions: signed records, commitments, packages, clips, cues and their renderings, 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,128 @@
//! `wai.live.recording` (extensions/live-recording.md): the committed corpus
//! `live-conformance/recordings/vectors.json` regenerates byte for byte from its fixed seeds,
//! every recording and clip in it gets the outcome it states, every reason the extension
//! adds appears in it, every take it says it reuses is capture binding's take byte for byte,
//! and the extension's §9 lists exactly the reasons the reference reports.
#![cfg(feature = "live_record")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::live_record::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn read(p: &str) -> String {
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
}
fn committed() -> String {
read("live-conformance/recordings/vectors.json")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
assert!(committed() == corpus::generate(), "live-conformance/recordings/vectors.json differs from `wai_live_record_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds is a recording's refusal, a clip's refusal or a
/// recording's verdict; the good recordings stand; the good clips §10 names are accepted,
/// the aligned one with twice the jitter as its bound.
#[test]
fn the_corpus_covers_every_reason_and_clip() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let recordings = doc["recordings"].as_array().unwrap();
let clips = doc["clips"].as_array().unwrap();
let mut seen: BTreeSet<String> = BTreeSet::new();
for v in recordings.iter().chain(clips) {
if let Some(r) = v["reason"].as_str() {
seen.insert(r.into());
}
if let Some(r) = v["complete"].as_str() {
seen.insert(r.into());
}
}
for r in Reason::ALL {
assert!(seen.contains(r.code()), "nothing in the corpus gives {r}");
}
for name in ["lesson_session", "signed_by_director", "with_composite", "late_recorder_span"] {
let v = recordings.iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no recording {name}"));
assert_eq!(v["stands"], true, "{name}");
assert_eq!(v["complete"], "holds", "{name}");
}
for name in ["single_chunk", "byte_range", "whole_take", "first_chunk", "last_chunk", "two_takes_aligned", "live_head"] {
let v = clips.iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no clip {name}"));
assert_eq!(v["outcome"], "accept", "{name}");
}
let aligned = clips.iter().find(|v| v["name"] == "two_takes_aligned").unwrap();
assert_eq!(aligned["facts"].as_array().unwrap().len(), 2);
assert_eq!(aligned["bound_us"], 1_000_000);
let missing = recordings.iter().find(|v| v["name"] == "missing_chunk").unwrap();
assert_eq!(missing["complete"], "chunk_missing");
}
/// The takes the corpus says it reuses are capture binding's, byte for byte: a recording's
/// take component holds that corpus's manifest, parts, receipts and consents; and a clip's
/// part names that corpus's vector only when every commitment it gives is a record of that
/// vector's chains and every chunk it gives is one of its parts.
#[test]
fn the_reused_takes_are_capture_bindings() {
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
let takes: serde_json::Value = serde_json::from_str(&read("live-conformance/takes/vectors.json")).unwrap();
let vector = |name: &str| takes["vectors"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap_or_else(|| panic!("no take vector {name}")).clone();
let mut reused = 0;
for v in doc["recordings"].as_array().unwrap() {
for r in v["reuses"].as_array().unwrap() {
let t = vector(r["vector"].as_str().unwrap());
let dir = format!("takes/{}/", r["take"].as_str().unwrap());
let files = v["files"].as_object().unwrap();
assert_eq!(t["take"], r["take"], "{}", v["name"]);
assert_eq!(files[&format!("{dir}manifest.wtm")], t["manifest"], "{}", v["name"]);
let mut parts: Vec<(u64, &serde_json::Value)> = files
.iter()
.filter_map(|(p, b)| Some((p.strip_prefix(&format!("{dir}chunk-"))?.strip_suffix(".bin")?.parse().ok()?, b)))
.collect();
parts.sort_by_key(|p| p.0);
let want: Vec<(u64, &serde_json::Value)> = t["parts"].as_array().unwrap().iter().map(|p| (p["index"].as_u64().unwrap(), &p["bytes"])).collect();
assert_eq!(parts, want, "{}", v["name"]);
let of = |pre: &str| files.iter().filter(|(p, _)| p.starts_with(&format!("{dir}{pre}"))).map(|(_, b)| b.clone()).collect::<Vec<_>>();
assert_eq!(serde_json::Value::Array(of("receipt-")), t["receipts"], "{}", v["name"]);
assert_eq!(serde_json::Value::Array(of("consent-")), t["consents"], "{}", v["name"]);
reused += 1;
}
}
for c in doc["clips"].as_array().unwrap() {
for p in c["parts"].as_array().unwrap() {
let Some(name) = p["take_vector"].as_str() else { continue };
let t = vector(name);
let records: BTreeSet<&str> = t["chains"].as_array().unwrap().iter().flat_map(|ch| ch.as_array().unwrap().iter().map(|r| r.as_str().unwrap())).collect();
let parts: BTreeSet<&str> = t["parts"].as_array().unwrap().iter().map(|p| p["bytes"].as_str().unwrap()).collect();
for r in p["commits"].as_array().unwrap() {
assert!(records.contains(r.as_str().unwrap()), "clip {}: a commitment not in {name}", c["name"]);
}
for b in p["bytes"].as_array().unwrap().iter().filter_map(|b| b.as_str()) {
assert!(parts.contains(b), "clip {}: a chunk not in {name}", c["name"]);
}
reused += 1;
}
}
assert!(reused > 0);
}
/// §9 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = read("extensions/live-recording.md");
let at = text.find("\n## 9. Reasons").expect("a §9");
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, "§9's table and Reason::ALL differ");
}

View file

@ -0,0 +1,67 @@
//! `wai.text.timed` (extensions/timed-text.md): the committed corpus
//! `live-conformance/captions/` regenerates byte for byte from its fixed seeds, holds no
//! other file, every case in it gets the outcome it states, it covers every reason and
//! every 608 code path, and the extension's §12 lists exactly the reasons the reference
//! reports.
#![cfg(feature = "timed_text")]
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use wai::timed_text::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> BTreeMap<String, String> {
let dir = wai_dir().join("live-conformance/captions");
std::fs::read_dir(&dir)
.expect("the committed corpus")
.flatten()
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(n, _)| n != "README.md")
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
.collect()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let files = committed();
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_timed_text_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every 608 code path §8 uses is in a committed file.
#[test]
fn the_corpus_covers_every_608_path() {
let mut seen = BTreeSet::new();
for (name, body) in committed() {
if name.ends_with(".608.hex") {
seen.extend(corpus::cc_paths(&body));
}
}
for p in corpus::CC_PATHS {
assert!(seen.contains(p), "no committed 608 file sends {p}");
}
}
/// §12 of the extension lists exactly the codes `Reason` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/timed-text.md")).expect("the extension");
let at = text.find("\n## 12. Reasons").expect("a §12");
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, "§12's table and Reason::ALL differ");
}