wai: timed text and live captions, wai.text.timed (L8) #303
73 changed files with 19424 additions and 46 deletions
53
.github/workflows/wai-conformance.yml
vendored
53
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
33
wai/SPEC.md
33
wai/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
703
wai/extensions/live-recording.md
Normal file
703
wai/extensions/live-recording.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
946
wai/extensions/timed-text.md
Normal file
946
wai/extensions/timed-text.md
Normal 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 `&`, `<` and `>`. 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 `&`, `<` and `>`; 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 `&`, `<`
|
||||
and `>`, 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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
55
wai/live-conformance/captions/README.md
Normal file
55
wai/live-conformance/captions/README.md
Normal 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`.
|
||||
230
wai/live-conformance/captions/cc_characters.pop-on.608.hex
Normal file
230
wai/live-conformance/captions/cc_characters.pop-on.608.hex
Normal 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
|
||||
23
wai/live-conformance/captions/cc_characters.ttml
Normal file
23
wai/live-conformance/captions/cc_characters.ttml
Normal 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>
|
||||
16
wai/live-conformance/captions/cc_characters.vtt
Normal file
16
wai/live-conformance/captions/cc_characters.vtt
Normal 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 ┌┐└┘ ¥ ¤ ¦
|
||||
‘ • Ã õ Ì ì Ò
|
||||
89
wai/live-conformance/captions/cc_rollup.pop-on.r25-1.608.hex
Normal file
89
wai/live-conformance/captions/cc_rollup.pop-on.r25-1.608.hex
Normal 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
|
||||
77
wai/live-conformance/captions/cc_rollup.roll-up-2.608.hex
Normal file
77
wai/live-conformance/captions/cc_rollup.roll-up-2.608.hex
Normal 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
|
||||
|
|
@ -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
|
||||
77
wai/live-conformance/captions/cc_rollup.roll-up-3.608.hex
Normal file
77
wai/live-conformance/captions/cc_rollup.roll-up-3.608.hex
Normal 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
|
||||
77
wai/live-conformance/captions/cc_rollup.roll-up-4.608.hex
Normal file
77
wai/live-conformance/captions/cc_rollup.roll-up-4.608.hex
Normal 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
|
||||
7
wai/live-conformance/captions/cues_overlap.vtt
Normal file
7
wai/live-conformance/captions/cues_overlap.vtt
Normal 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
|
||||
109
wai/live-conformance/captions/disclosure_mix.pop-on.608.hex
Normal file
109
wai/live-conformance/captions/disclosure_mix.pop-on.608.hex
Normal 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
|
||||
8
wai/live-conformance/captions/disclosure_mix.seg0.vtt
Normal file
8
wai/live-conformance/captions/disclosure_mix.seg0.vtt
Normal 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>
|
||||
12
wai/live-conformance/captions/disclosure_mix.seg1.vtt
Normal file
12
wai/live-conformance/captions/disclosure_mix.seg1.vtt
Normal 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>
|
||||
5
wai/live-conformance/captions/disclosure_mix.seg2.vtt
Normal file
5
wai/live-conformance/captions/disclosure_mix.seg2.vtt
Normal 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>
|
||||
23
wai/live-conformance/captions/disclosure_mix.ttml
Normal file
23
wai/live-conformance/captions/disclosure_mix.ttml
Normal 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>
|
||||
14
wai/live-conformance/captions/disclosure_mix.vtt
Normal file
14
wai/live-conformance/captions/disclosure_mix.vtt
Normal 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>
|
||||
31
wai/live-conformance/captions/hrm_exceeded.vtt
Normal file
31
wai/live-conformance/captions/hrm_exceeded.vtt
Normal 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
|
||||
136
wai/live-conformance/captions/lesson_human.pop-on.608.hex
Normal file
136
wai/live-conformance/captions/lesson_human.pop-on.608.hex
Normal 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
|
||||
5
wai/live-conformance/captions/lesson_human.seg0.vtt
Normal file
5
wai/live-conformance/captions/lesson_human.seg0.vtt
Normal 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.
|
||||
9
wai/live-conformance/captions/lesson_human.seg1.vtt
Normal file
9
wai/live-conformance/captions/lesson_human.seg1.vtt
Normal 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>
|
||||
9
wai/live-conformance/captions/lesson_human.seg2.vtt
Normal file
9
wai/live-conformance/captions/lesson_human.seg2.vtt
Normal 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.
|
||||
9
wai/live-conformance/captions/lesson_human.seg3.vtt
Normal file
9
wai/live-conformance/captions/lesson_human.seg3.vtt
Normal 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.
|
||||
24
wai/live-conformance/captions/lesson_human.ttml
Normal file
24
wai/live-conformance/captions/lesson_human.ttml
Normal 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>
|
||||
18
wai/live-conformance/captions/lesson_human.vtt
Normal file
18
wai/live-conformance/captions/lesson_human.vtt
Normal 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.
|
||||
67
wai/live-conformance/captions/lesson_machine.pop-on.608.hex
Normal file
67
wai/live-conformance/captions/lesson_machine.pop-on.608.hex
Normal 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
|
||||
|
|
@ -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
|
||||
5
wai/live-conformance/captions/lesson_machine.seg0.vtt
Normal file
5
wai/live-conformance/captions/lesson_machine.seg0.vtt
Normal 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>
|
||||
8
wai/live-conformance/captions/lesson_machine.seg1.vtt
Normal file
8
wai/live-conformance/captions/lesson_machine.seg1.vtt
Normal 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>
|
||||
8
wai/live-conformance/captions/lesson_machine.seg2.vtt
Normal file
8
wai/live-conformance/captions/lesson_machine.seg2.vtt
Normal 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>
|
||||
22
wai/live-conformance/captions/lesson_machine.ttml
Normal file
22
wai/live-conformance/captions/lesson_machine.ttml
Normal 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>
|
||||
10
wai/live-conformance/captions/lesson_machine.vtt
Normal file
10
wai/live-conformance/captions/lesson_machine.vtt
Normal 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>
|
||||
22
wai/live-conformance/captions/mid_stream_join.ttml
Normal file
22
wai/live-conformance/captions/mid_stream_join.ttml
Normal 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>
|
||||
10
wai/live-conformance/captions/mid_stream_join.vtt
Normal file
10
wai/live-conformance/captions/mid_stream_join.vtt
Normal 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>
|
||||
4
wai/live-conformance/captions/style_not_expressible.vtt
Normal file
4
wai/live-conformance/captions/style_not_expressible.vtt
Normal 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
|
||||
195
wai/live-conformance/captions/styles_and_anchors.pop-on.608.hex
Normal file
195
wai/live-conformance/captions/styles_and_anchors.pop-on.608.hex
Normal 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
|
||||
|
|
@ -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.
|
||||
13
wai/live-conformance/captions/styles_and_anchors.seg1.vtt
Normal file
13
wai/live-conformance/captions/styles_and_anchors.seg1.vtt
Normal 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 > 3 & 2 < 4 --> 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
|
||||
13
wai/live-conformance/captions/styles_and_anchors.seg2.vtt
Normal file
13
wai/live-conformance/captions/styles_and_anchors.seg2.vtt
Normal 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.
|
||||
|
|
@ -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.
|
||||
27
wai/live-conformance/captions/styles_and_anchors.ttml
Normal file
27
wai/live-conformance/captions/styles_and_anchors.ttml
Normal 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 > 3 & 2 < 4 --> 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>
|
||||
28
wai/live-conformance/captions/styles_and_anchors.vtt
Normal file
28
wai/live-conformance/captions/styles_and_anchors.vtt
Normal 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 > 3 & 2 < 4 --> 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.
|
||||
3063
wai/live-conformance/captions/vectors.json
Normal file
3063
wai/live-conformance/captions/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
850
wai/live-conformance/live_record_verify.py
Normal file
850
wai/live-conformance/live_record_verify.py
Normal 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]))
|
||||
122
wai/live-conformance/recordings/README.md
Normal file
122
wai/live-conformance/recordings/README.md
Normal 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.
|
||||
3125
wai/live-conformance/recordings/vectors.json
Normal file
3125
wai/live-conformance/recordings/vectors.json
Normal file
File diff suppressed because one or more lines are too long
1614
wai/live-conformance/timed_text_verify.py
Normal file
1614
wai/live-conformance/timed_text_verify.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
53
wai/wai-rs/src/bin/wai_live_record_vectors.rs
Normal file
53
wai/wai-rs/src/bin/wai_live_record_vectors.rs
Normal 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
|
||||
}
|
||||
75
wai/wai-rs/src/bin/wai_timed_text_vectors.rs
Normal file
75
wai/wai-rs/src/bin/wai_timed_text_vectors.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1528
wai/wai-rs/src/live_record.rs
Normal file
1528
wai/wai-rs/src/live_record.rs
Normal file
File diff suppressed because it is too large
Load diff
1370
wai/wai-rs/src/live_record/corpus.rs
Normal file
1370
wai/wai-rs/src/live_record/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
1632
wai/wai-rs/src/timed_text.rs
Normal file
File diff suppressed because it is too large
Load diff
796
wai/wai-rs/src/timed_text/cc608.rs
Normal file
796
wai/wai-rs/src/timed_text/cc608.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
1138
wai/wai-rs/src/timed_text/corpus.rs
Normal file
1138
wai/wai-rs/src/timed_text/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"),
|
||||
|
|
|
|||
128
wai/wai-rs/tests/live_record.rs
Normal file
128
wai/wai-rs/tests/live_record.rs
Normal 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");
|
||||
}
|
||||
67
wai/wai-rs/tests/timed_text.rs
Normal file
67
wai/wai-rs/tests/timed_text.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue