From 632f8cf394d7fd8b49cc6688ac42ce0f5457d2fc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:17:48 -0400 Subject: [PATCH 1/6] wai: the developer's path, hello, create, dev and verify, a floor build with no system library, and nine examples wai-rs gains the feature floor (the mandatory floor of SPEC 5: PNG, FLAC and zstd, decoded with no system codec library) and quickstart (the floor, and the receipt checks hello and verify run). The wai command line gains four verbs, one module each, named in one table (dx::VERBS): - hello: name, dispatch, rebuild and receipt on a picture made here, the receipt laid out as http-delivery 8 lays receipts beside an object; - create: a new project from one of the examples, whose files the binary holds; tests/cli_dx.rs holds them to wai/examples/ byte for byte; - dev: the relay, the room service and the room page on one loopback origin, as a production front serves them; --check checks each piece through it; - verify: the receipt indexes under a directory, checked offline. The project file a template writes, wai.json, is read in one place (dx/project.rs). wai/examples/ holds video-room, watch-together, messaging, encoder-ingest, package-and-deliver, verify-receipts, apple-app, android-app and remote-desktop, each with a README and a test that runs it through wai create and wai dev. wai/quickstart/ builds the room client and the archive that holds wai, the relay, the room service, the remote host and the packager. Co-Authored-By: Claude Opus 5.5 --- wai/examples/android-app/.gitignore | 3 + wai/examples/android-app/README.md | 29 + .../android-app/replay/build.gradle.kts | 32 + .../replay/src/main/kotlin/Replay.kt | 40 + wai/examples/android-app/sample-session.json | 659 +++++++++++ wai/examples/android-app/settings.gradle.kts | 27 + .../android-app/test/android-app.test.sh | 26 + wai/examples/android-app/wai.json | 5 + wai/examples/apple-app/.gitignore | 2 + wai/examples/apple-app/Package.swift | 20 + wai/examples/apple-app/README.md | 31 + .../apple-app/Sources/WaiApp/PlayerApp.swift | 50 + .../apple-app/Sources/WaiApp/Replay.swift | 47 + .../apple-app/Sources/WaiApp/main.swift | 13 + wai/examples/apple-app/sample-session.json | 659 +++++++++++ wai/examples/apple-app/test/apple-app.test.sh | 24 + wai/examples/apple-app/wai.json | 5 + wai/examples/encoder-ingest/.gitignore | 1 + wai/examples/encoder-ingest/Cargo.toml | 25 + wai/examples/encoder-ingest/README.md | 30 + wai/examples/encoder-ingest/src/main.rs | 226 ++++ .../test/encoder-ingest.test.sh | 34 + wai/examples/encoder-ingest/wai.json | 5 + wai/examples/messaging/.gitignore | 1 + wai/examples/messaging/README.md | 31 + wai/examples/messaging/index.html | 24 + .../messaging/test/messaging.test.mjs | 62 + wai/examples/messaging/wai.json | 6 + wai/examples/package-and-deliver/.gitignore | 3 + wai/examples/package-and-deliver/README.md | 29 + wai/examples/package-and-deliver/floor/v.obu | Bin 0 -> 6546 bytes wai/examples/package-and-deliver/index.html | 27 + wai/examples/package-and-deliver/pack.sh | 16 + wai/examples/package-and-deliver/player.js | 30 + wai/examples/package-and-deliver/policy.toml | 47 + wai/examples/package-and-deliver/test-key.hex | 1 + .../test/package-and-deliver.test.mjs | 32 + .../test/package-and-deliver.test.sh | 28 + wai/examples/package-and-deliver/wai.json | 6 + wai/examples/remote-desktop/.gitignore | 1 + wai/examples/remote-desktop/README.md | 31 + wai/examples/remote-desktop/host.sh | 22 + .../test/remote-desktop.test.mjs | 40 + .../test/remote-desktop.test.sh | 41 + wai/examples/remote-desktop/wai.json | 6 + wai/examples/verify-receipts/.gitignore | 3 + wai/examples/verify-receipts/README.md | 29 + .../verify-receipts/python/_blake3.py | 113 ++ .../verify-receipts/python/_ed25519.py | 182 +++ .../verify-receipts/python/verify_receipts.py | 124 ++ wai/examples/verify-receipts/rust/Cargo.toml | 14 + wai/examples/verify-receipts/rust/src/main.rs | 70 ++ .../test/verify-receipts.test.sh | 59 + wai/examples/verify-receipts/wai.json | 5 + wai/examples/video-room/.gitignore | 1 + wai/examples/video-room/README.md | 25 + wai/examples/video-room/index.html | 38 + .../video-room/test/video-room.test.mjs | 46 + wai/examples/video-room/wai.json | 6 + wai/examples/watch-together/.gitignore | 1 + wai/examples/watch-together/README.md | 28 + wai/examples/watch-together/index.html | 22 + .../test/watch-together.test.mjs | 52 + wai/examples/watch-together/wai.json | 6 + wai/quickstart/README.md | 35 + wai/quickstart/build-web.sh | 44 + wai/quickstart/build.sh | 73 ++ wai/quickstart/test/lib.mjs | 130 +++ wai/quickstart/test/package-lock.json | 58 + wai/quickstart/test/package.json | 10 + wai/quickstart/test/run-examples.sh | 57 + wai/wai-rs/Cargo.toml | 27 +- wai/wai-rs/src/bin/wai.rs | 77 +- wai/wai-rs/src/bin/wai_cli/dx/create.rs | 159 +++ wai/wai-rs/src/bin/wai_cli/dx/dev.rs | 1006 +++++++++++++++++ wai/wai-rs/src/bin/wai_cli/dx/hello.rs | 161 +++ wai/wai-rs/src/bin/wai_cli/dx/mod.rs | 77 ++ wai/wai-rs/src/bin/wai_cli/dx/project.rs | 149 +++ wai/wai-rs/src/bin/wai_cli/dx/templates.rs | 130 +++ wai/wai-rs/src/bin/wai_cli/dx/verify.rs | 142 +++ wai/wai-rs/src/codecs/audio.rs | 13 +- wai/wai-rs/src/codecs/image.rs | 18 +- wai/wai-rs/src/codecs/mod.rs | 14 +- wai/wai-rs/src/codecs/text.rs | 12 +- wai/wai-rs/src/lib.rs | 2 +- wai/wai-rs/tests/cli_dx.rs | 138 +++ wai/wai-rs/tests/floor.rs | 133 +++ 87 files changed, 5942 insertions(+), 24 deletions(-) create mode 100644 wai/examples/android-app/.gitignore create mode 100644 wai/examples/android-app/README.md create mode 100644 wai/examples/android-app/replay/build.gradle.kts create mode 100644 wai/examples/android-app/replay/src/main/kotlin/Replay.kt create mode 100644 wai/examples/android-app/sample-session.json create mode 100644 wai/examples/android-app/settings.gradle.kts create mode 100755 wai/examples/android-app/test/android-app.test.sh create mode 100644 wai/examples/android-app/wai.json create mode 100644 wai/examples/apple-app/.gitignore create mode 100644 wai/examples/apple-app/Package.swift create mode 100644 wai/examples/apple-app/README.md create mode 100644 wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift create mode 100644 wai/examples/apple-app/Sources/WaiApp/Replay.swift create mode 100644 wai/examples/apple-app/Sources/WaiApp/main.swift create mode 100644 wai/examples/apple-app/sample-session.json create mode 100755 wai/examples/apple-app/test/apple-app.test.sh create mode 100644 wai/examples/apple-app/wai.json create mode 100644 wai/examples/encoder-ingest/.gitignore create mode 100644 wai/examples/encoder-ingest/Cargo.toml create mode 100644 wai/examples/encoder-ingest/README.md create mode 100644 wai/examples/encoder-ingest/src/main.rs create mode 100755 wai/examples/encoder-ingest/test/encoder-ingest.test.sh create mode 100644 wai/examples/encoder-ingest/wai.json create mode 100644 wai/examples/messaging/.gitignore create mode 100644 wai/examples/messaging/README.md create mode 100644 wai/examples/messaging/index.html create mode 100644 wai/examples/messaging/test/messaging.test.mjs create mode 100644 wai/examples/messaging/wai.json create mode 100644 wai/examples/package-and-deliver/.gitignore create mode 100644 wai/examples/package-and-deliver/README.md create mode 100644 wai/examples/package-and-deliver/floor/v.obu create mode 100644 wai/examples/package-and-deliver/index.html create mode 100755 wai/examples/package-and-deliver/pack.sh create mode 100644 wai/examples/package-and-deliver/player.js create mode 100644 wai/examples/package-and-deliver/policy.toml create mode 100644 wai/examples/package-and-deliver/test-key.hex create mode 100644 wai/examples/package-and-deliver/test/package-and-deliver.test.mjs create mode 100755 wai/examples/package-and-deliver/test/package-and-deliver.test.sh create mode 100644 wai/examples/package-and-deliver/wai.json create mode 100644 wai/examples/remote-desktop/.gitignore create mode 100644 wai/examples/remote-desktop/README.md create mode 100755 wai/examples/remote-desktop/host.sh create mode 100644 wai/examples/remote-desktop/test/remote-desktop.test.mjs create mode 100755 wai/examples/remote-desktop/test/remote-desktop.test.sh create mode 100644 wai/examples/remote-desktop/wai.json create mode 100644 wai/examples/verify-receipts/.gitignore create mode 100644 wai/examples/verify-receipts/README.md create mode 100644 wai/examples/verify-receipts/python/_blake3.py create mode 100644 wai/examples/verify-receipts/python/_ed25519.py create mode 100644 wai/examples/verify-receipts/python/verify_receipts.py create mode 100644 wai/examples/verify-receipts/rust/Cargo.toml create mode 100644 wai/examples/verify-receipts/rust/src/main.rs create mode 100755 wai/examples/verify-receipts/test/verify-receipts.test.sh create mode 100644 wai/examples/verify-receipts/wai.json create mode 100644 wai/examples/video-room/.gitignore create mode 100644 wai/examples/video-room/README.md create mode 100644 wai/examples/video-room/index.html create mode 100644 wai/examples/video-room/test/video-room.test.mjs create mode 100644 wai/examples/video-room/wai.json create mode 100644 wai/examples/watch-together/.gitignore create mode 100644 wai/examples/watch-together/README.md create mode 100644 wai/examples/watch-together/index.html create mode 100644 wai/examples/watch-together/test/watch-together.test.mjs create mode 100644 wai/examples/watch-together/wai.json create mode 100644 wai/quickstart/README.md create mode 100755 wai/quickstart/build-web.sh create mode 100755 wai/quickstart/build.sh create mode 100644 wai/quickstart/test/lib.mjs create mode 100644 wai/quickstart/test/package-lock.json create mode 100644 wai/quickstart/test/package.json create mode 100755 wai/quickstart/test/run-examples.sh create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/create.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/dev.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/hello.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/mod.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/project.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/templates.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/verify.rs create mode 100644 wai/wai-rs/tests/cli_dx.rs create mode 100644 wai/wai-rs/tests/floor.rs diff --git a/wai/examples/android-app/.gitignore b/wai/examples/android-app/.gitignore new file mode 100644 index 000000000..20023cbdb --- /dev/null +++ b/wai/examples/android-app/.gitignore @@ -0,0 +1,3 @@ +.gradle/ +build/ +replay/build/ diff --git a/wai/examples/android-app/README.md b/wai/examples/android-app/README.md new file mode 100644 index 000000000..b6aeaf8f2 --- /dev/null +++ b/wai/examples/android-app/README.md @@ -0,0 +1,29 @@ +# The native sink in Kotlin, for Android + +**What it shows.** The WAI native sink's Kotlin API, the one an Android app calls. A recorded +playback session goes through the sink call by call: each playlist verified against its +receipt, each object against the hash its name states, the player's observations, its end. +The sink signs an acknowledgement of what was received and, at the end, a bundle; the program +verifies the bundle as the stream's owner does. + +**Run it.** The sink is the `wai-sink-android` project of the WAI repository; build its host +library once (JDK 17 and Rust), then point this project at it: + +```sh +wai create my-android --template android-app +cd /wai/wai-sink-android && scripts/build-native.sh host +cd my-android +export WAI_SINK_ANDROID=/wai/wai-sink-android +gradle :replay:run # Gradle 8.13, as the SDK pins it +``` + +**What you see.** How many playlists and objects went through the sink, and that the bundle +verifies. + +**In an Android app.** Build the SDK's AAR (`scripts/gradle.sh :sink:assembleRelease`, with the +Android SDK and NDK) and give the player a `WaiPlayerSession(context, url, config, +WaiViewAttention(view))`: the same calls, on the path of the player's requests. The SDK's +README covers it. + +**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the +instructions an assistant reads and connects it to WAI's tools. diff --git a/wai/examples/android-app/replay/build.gradle.kts b/wai/examples/android-app/replay/build.gradle.kts new file mode 100644 index 000000000..339eb0c3f --- /dev/null +++ b/wai/examples/android-app/replay/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("org.jetbrains.kotlin.jvm") + application +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":core")) +} + +application { + mainClass.set("ReplayKt") +} + +// The JNI layer over libwai, which the SDK's `scripts/build-native.sh host` builds. +tasks.named("run") { + systemProperty("java.library.path", project(":core").layout.buildDirectory.dir("native").get().asFile.path) + workingDir = rootDir + args("sample-session.json") +} diff --git a/wai/examples/android-app/replay/src/main/kotlin/Replay.kt b/wai/examples/android-app/replay/src/main/kotlin/Replay.kt new file mode 100644 index 000000000..5d6f099d5 --- /dev/null +++ b/wai/examples/android-app/replay/src/main/kotlin/Replay.kt @@ -0,0 +1,40 @@ +// A recorded playback session, replayed through the WAI native sink call by call: the playlists +// with their receipts, the objects the player received, its observations, its end. The bundle +// the sink signs at the end must verify as the stream's owner verifies it. +import java.io.File +import kotlin.system.exitProcess +import wai.sink.Json +import wai.sink.WaiSink + +fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(2 * it, 2 * it + 2).toInt(16).toByte() } + +fun main(args: Array) { + @Suppress("UNCHECKED_CAST") + val doc = Json.obj(File(args.firstOrNull() ?: "sample-session.json").readBytes()) + @Suppress("UNCHECKED_CAST") + val objects = doc["objects"] as Map + fun bytes(name: Any?): ByteArray? = (name as? String)?.let { hex(objects.getValue(it)) } + @Suppress("UNCHECKED_CAST") + val sink = WaiSink.open(doc["config"] as Map, hex(doc["seed"] as String), (doc["start_us"] as Number).toLong()) + var bundle = ByteArray(0) + var verified = 0 + @Suppress("UNCHECKED_CAST") + for (d in doc["calls"] as List>) { + val t = { (d["t_us"] as Number).toLong() } + when (d["call"]) { + "playlist" -> { sink.playlist(d["url"] as String, bytes(d["body"])!!, bytes(d["receipt"])); verified++ } + "object" -> { @Suppress("UNCHECKED_CAST") sink.verifyObject(d["meta"] as Map, bytes(d["body"])!!); verified++ } + "event" -> sink.event(d["name"] as String, t()) + "observe" -> { @Suppress("UNCHECKED_CAST") sink.observe(d["obs"] as Map) } + "end" -> bundle = sink.end(WaiSink.Ended.of(d["ended"] as String)!!, t()) + } + } + sink.close() + @Suppress("UNCHECKED_CAST") + val out = WaiSink.verifyBundle(bundle, hex(doc["owner"] as String), doc["channels"] as List) + @Suppress("UNCHECKED_CAST") + val want = doc["outcome"] as Map + val ok = out["outcome"] == "accept" && out["digest"] == want["digest"] + println("replay: $verified playlists and objects through the sink; the bundle ${if (ok) "verifies" else "does NOT verify"} as its owner verifies it (${out["outcome"]}, ${out["presented_ms"]} ms presented)") + exitProcess(if (ok) 0 else 1) +} diff --git a/wai/examples/android-app/sample-session.json b/wai/examples/android-app/sample-session.json new file mode 100644 index 000000000..ff1d176c4 --- /dev/null +++ b/wai/examples/android-app/sample-session.json @@ -0,0 +1,659 @@ +{ + "note": "A playback session recorded from the native sink corpus (wai/live-conformance/native-sink, case signed-vod): what a player asked for and received, in order. The app replays it through the sink.", + "owner": "a42c0b85c7d0f73cde731efc13f7892207283355bff6f3afa73831955e63da8f", + "channels": [ + "film-library", + "news-east" + ], + "config": { + "channel": "film-library", + "owner": "a42c0b85c7d0f73cde731efc13f7892207283355bff6f3afa73831955e63da8f" + }, + "seed": "394db3cb0099f03957c158322aa47a19707ea5e79651ac8739d16a3613cf51f7", + "start_us": 1792886405000000, + "calls": [ + { + "call": "playlist", + "url": "https://media.example/title/master.m3u8", + "body": "multivariant", + "receipt": null + }, + { + "call": "playlist", + "url": "https://media.example/title/v/v.m3u8", + "body": "vod", + "receipt": "vod-receipt" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/master.m3u8", + "t_us": 1792886405010000 + }, + "body": "multivariant" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/v.m3u8", + "t_us": 1792886405020000 + }, + "body": "vod" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/init.mp4", + "t_us": 1792886405050000 + }, + "body": "init" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/420ae4c941bba6e12a6c7fdb7d01dc69170ecb4240dffd2e86808ddf87baa12a.m4s", + "t_us": 1792886405200000 + }, + "body": "f0" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/04c948608aaa7b5d78ad5355042a2ff65b0f75d9d4b1ad78f3ebffb886a74be6.m4s", + "t_us": 1792886405350000 + }, + "body": "f1" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/2f93f0ad449f9cc881b7816c6ab33fd445037d6538b445bcc76431f9c9f91c5b.m4s", + "t_us": 1792886405500000 + }, + "body": "f2" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/06d5cd79ba3860cccdc252fe915de0902f941a3c6a000295714f7cab193aa6cf.m4s", + "t_us": 1792886405650000 + }, + "body": "f3" + }, + { + "call": "object", + "meta": { + "url": "https://media.example/title/v/99a24de04f7be9fc21097e69d2d61b222e458b1f93b8082026a9874aeb9f4c15.m4s", + "t_us": 1792886405800000 + }, + "body": "f4" + }, + { + "call": "event", + "name": "present", + "t_us": 1792886406000000 + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886406800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886407800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886408800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886409800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886410800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886411800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886412800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413200000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413400000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413600000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886413800000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "observe", + "obs": { + "t_us": 1792886414000000, + "playing": true, + "visible_bp": 10000, + "audible": true, + "program_ms": null, + "seeked": false, + "interaction": false + } + }, + { + "call": "event", + "name": "end", + "t_us": 1792886414000000 + }, + { + "call": "end", + "ended": "ended", + "t_us": 1792886414000000 + } + ], + "objects": { + "multivariant": "234558544d33550a234558542d582d56455253494f4e3a370a234558542d582d494e444550454e44454e542d5345474d454e54530a234558542d582d53545245414d2d494e463a42414e4457494454483d31303239352c415645524147452d42414e4457494454483d393032362c434f444543533d22617630312e302e30304d2e3038222c5245534f4c5554494f4e3d36347833360a762e6d3375380a", + "vod": "234558544d33550a234558542d582d56455253494f4e3a370a234558542d582d5441524745544455524154494f4e3a330a234558542d582d4d454449412d53455155454e43453a300a234558542d582d504c41594c4953542d545950453a564f440a234558542d582d494e444550454e44454e542d5345474d454e54530a234558542d582d4d41503a5552493d22696e69742e6d7034220a23455854494e463a322e3430302c0a343230616534633934316262613665313261366337666462376430316463363931373065636234323430646666643265383638303864646638376261613132612e6d34730a23455854494e463a312e3030302c0a303463393438363038616161376235643738616435333535303432613266663635623066373564396434623161643738663365626666623838366137346265362e6d34730a23455854494e463a322e3030302c0a326639336630616434343966396363383831623738313663366162333366643434353033376436353338623434356263633736343331663963396639316335622e6d34730a23455854494e463a312e3030302c0a303664356364373962613338363063636364633235326665393135646530393032663934316133633661303030323935373134663763616231393361613663662e6d34730a23455854494e463a312e3630302c0a393961323464653034663762653966633231303937653639643264363162323232653435386231663933623830383230323661393837346165623966346331352e6d34730a234558542d582d454e444c4953540a", + "vod-receipt": "7b226b696e64223a227761692e766964656f2e6d616e6966657374222c226d616e69666573745f68617368223a2235393637396132336363323838653139323135303631393835616339623231663734396239616362613934343737373739313739393063343438663834353962222c2273657373696f6e5f636c6173735f68617368223a2237343834633836626266623236326566313537633436376364363539613061626438343134346435653931363865343631633831396463393534343836373766222c22636c617373223a7b22636f6e74656e745f6964223a227469746c652d31222c22656e7469746c656d656e74223a227075626c6963222c2267656f223a22222c22626c61636b6f757473223a5b5d2c2261645f706f645f6964223a22222c2277696e646f77223a307d2c227365676d656e7473223a5b7b22696e646578223a302c226b696e64223a22636f6e74656e74222c22757269223a22343230616534633934316262613665313261366337666462376430316463363931373065636234323430646666643265383638303864646638376261613132612e6d3473222c226475726174696f6e5f6d73223a323430302c2261645f6964223a6e756c6c7d2c7b22696e646578223a312c226b696e64223a22636f6e74656e74222c22757269223a22303463393438363038616161376235643738616435333535303432613266663635623066373564396434623161643738663365626666623838366137346265362e6d3473222c226475726174696f6e5f6d73223a313030302c2261645f6964223a6e756c6c7d2c7b22696e646578223a322c226b696e64223a22636f6e74656e74222c22757269223a22326639336630616434343966396363383831623738313663366162333366643434353033376436353338623434356263633736343331663963396639316335622e6d3473222c226475726174696f6e5f6d73223a323030302c2261645f6964223a6e756c6c7d2c7b22696e646578223a332c226b696e64223a22636f6e74656e74222c22757269223a22303664356364373962613338363063636364633235326665393135646530393032663934316133633661303030323935373134663763616231393361613663662e6d3473222c226475726174696f6e5f6d73223a313030302c2261645f6964223a6e756c6c7d2c7b22696e646578223a342c226b696e64223a22636f6e74656e74222c22757269223a22393961323464653034663762653966633231303937653639643264363162323232653435386231663933623830383230323661393837346165623966346331352e6d3473222c226475726174696f6e5f6d73223a313630302c2261645f6964223a6e756c6c7d5d2c226d65726b6c655f726f6f74223a2266366138656566666463333263336135313035336665333862623835346534663834356337326435613936663931353762353861653463353065303236343864222c22776f726b5f636f6d706f73655f6f7073223a352c22766965776572735f736572766564223a312c226a6f756c65735f6d6963726f223a302c226772616e74223a7b226772616e745f68617368223a2230303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030222c226361706162696c697479223a22766964656f2e6d616e6966657374222c226a6f756c655f6365696c696e675f6d6963726f223a6e756c6c2c2266756e64735f6365696c696e67223a6e756c6c7d2c22706172656e745f726563656970745f68617368223a6e756c6c2c227369676e65725f7075626b6579223a2233633462613937356434626666306565336337333766303434323934346631343434383163393532326230383133643061303164303162396632366434653037222c227369676e65725f6964223a226469643a6578616d706c653a7075626c6973686572222c22736967223a226661383235326366313435613635633731653238363961653438653337393437353530356161363032343536336237636263396635663834616137353036613235333036653062356434326431306561663832363266646263646266316265666261336538346633343433376331396137313239316231346166633333373038222c2272656e646572223a7b2276657273696f6e223a372c226d656469615f73657175656e6365223a302c22646973636f6e74696e756974795f73657175656e6365223a302c227461726765745f6475726174696f6e5f73223a332c22706172745f7461726765745f6d73223a6e756c6c2c22706172745f686f6c645f6261636b5f6d73223a6e756c6c2c2263616e5f626c6f636b5f72656c6f6164223a66616c73652c22656e645f6c697374223a747275652c22706c61796c6973745f74797065223a22564f44222c22696e646570656e64656e745f7365676d656e7473223a747275652c226d61705f757269223a22696e69742e6d7034227d2c22657874726173223a5b5d7d0a", + "init": "0000001c66747970636d666300000000636d666369736f3661763031000002586d6f6f760000006c6d76686400000000000000000000000000000019000000000001000001000000000000000000000000010000000000000000000000000000000100000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002000001bc7472616b0000005c746b6864000000030000000000000000000000010000000000000000000000000000000000000000000000000001000000000000000000000000000000010000000000000000000000000000400000000040000000240000000001586d646961000000206d646864000000000000000000000000000000190000000055c400000000002668646c72000000000000000076696465000000000000000000000000766964656f000000010a6d696e6600000014766d68640000000100000000000000000000002464696e660000001c6472656600000000000000010000000c75726c2000000001000000ca7374626c0000007e7374736400000000000000010000006e6176303100000000000000010000000000000000000000000000000000400024004800000048000000000000000100000000000000000000000000000000000000000000000000000000000000000018ffff000000186176314381000c000a0a00000002aff19b5f30080000001073747473000000000000000000000010737473630000000000000000000000147374737a000000000000000000000000000000107374636f0000000000000000000000286d7665780000002074726578000000000000000100000001000000000000000000000000", + "f0": "000003286d6f6f66000000106d66686400000000000000010000031074726166000000107466686400020000000000010000001474666474010000000000000000000000000002e47472756e000007010000003c00000330000000010000011802000000000000010000001f01010000000000010000001b01010000000000010000001e01010000000000010000001a01010000000000010000001901010000000000010000001b01010000000000010000001d01010000000000010000001801010000000000010000001801010000000000010000001901010000000000010000001901010000000000010000001701010000000000010000001901010000000000010000001701010000000000010000001801010000000000010000001e01010000000000010000001a01010000000000010000001801010000000000010000001701010000000000010000001901010000000000010000001901010000000000010000001601010000000000010000001901010000000000010000001901010000000000010000001801010000000000010000001801010000000000010000001801010000000000010000001701010000000000010000001701010000000000010000002301010000000000010000001f01010000000000010000001701010000000000010000002001010000000000010000001901010000000000010000001a01010000000000010000001901010000000000010000001c01010000000000010000001801010000000000010000001801010000000000010000001801010000000000010000001701010000000000010000001b01010000000000010000001b01010000000000010000001a01010000000000010000001801010000000000010000001901010000000000010000001801010000000000010000001e01010000000000010000001c01010000000000010000001a01010000000000010000001901010000000000010000001801010000000000010000001801010000000000010000001801010000000000010000001a01010000000000010000001701010000000000010000001801010000000000010000001801010000000000010000001b01010000000007056d6461740a0a00000002aff19b5f300832890210009b0000020b0002d800ce1a563008d4ca3e2e16ec6f636ea85ac0193ff92d7f7989299e359d284aa3a420d2893c63c21fed652531c1cd6e88ed1419f54561fe9bc57ee2715d85ce85bd088b1668dfa1a42b54ef12a611fd5cf1cd0f7d90cdc79dc5dafe8f97b6ed3e1f761a8f745aee6b4e66f2869242a680d9e817f34b972859d396f4d963a1a3e53f017f116909fbc6d60f8f2d0a86225b1eef95be15e199f7ea63d1eeb196b10b54c09f1463046579924e9c30f9469a97b78813710fd5a6870a142d2ebd2efb5054a0879f8bb56e984e7db71bbe4f31277c353905c67700c5977e3d02734412f06c7056d4a934888db5fbb5e929e3bd8165b468a227bd8d30cf8b1deda859c0321d3003c080000006d00000028000008000cfee0db7d6fe60e1bd86a73240321930040104000006d00000028000008000cd861752553fe53140321c30060208800006d00000028000008000cc2a55293a8837d8603ddca832183008040d100006d00000028000008000cfe5d7f95a9e79143217300a0811a04006d00000028000008000c7550f99e3d0603219300c1016308806d00000028000008000c58cd5222c17f41d2a321b300e201ac0d106d00000028000008000c3a23dfa6661dfb95f325032163010009f511a06d00000028000008000c1f386102e80321630120107e16306d00000028b80008000bf9af923d23a321730140208f1ac06d00000028000008000d26f88070a858032173016040d11f506d00000028b80008000bb39f8db5303c0321530180811a07e06d00000028000008000bb634c73103217301a1016308f06d00000028b00008000b76c6bda4e98303215301c201ac0d106d00000028000008000b9708d53d83216301e009f511a06d00000028b80008000b3d867aa48e0321c3021c107e16306d00000028b80008000d0510040d7408e3cb1840280321830220207e56006d00000028000008000d59f1b7d5855b5e032163024040cf5a806d00000028b00008000acfb5178d64032153026081195f006d00000028000008000b56628f3c2321730281016347806d00000028000008000ac778a1f0915a03217302a201ac4c806d00000028000008000d7f7c196ec2f803214302c009f551806d00000028000008000ab3df08a3217302e0207e56006d00000028b80008000b730f2c8e543e232173030040cf5a806d00000028b0002800085795f8f8306f832163032081195f006d00000028000008000db85494205f5321630341016347806d00000028b00008000a730b293e0ac32163036201ac4c806d00000028000008000dcce854cf56932153038009f551806d00000028b00008000a35c939aab3215303a0207e56006d00000028000008000dd2ae8da883221303c040cf5a806d000c3000028b8002800bfdc4c45194543e4a16af830e0a47d36321d303e081195f006d00061800028b8002800c312c6dc53893f96b1413180321530419016347806c000618000280000080098036cc8321e304220163c5006d00082000028b8002800d78c61e69d32274c3c1e5b5efe32173044009eccd006d0006180002800000800c133d359bc80321830460207dd1006d0006180002800000800aff4447169887632173048040cfd5006d00082000028b8000800c88dd22544e0321a304a08119dd006d0008200002800000800b399959006a0f842863216304c20163c5006d0008200002800000800b6ea6d64a83216304e009eccd006d0006180002800000800aa1c06f4e0321630500207dd1006d0006180002800000800c155b9e32032153052040cfd5006d0006180002800000800a70b1bc43219305408119dd006d0008200002808000800dc3ab0503e0de5793219305620163c5006d0008200002800000800a3a3fb2a57ecf60432183058009eccd006d0006180002800000800480c25c4a55d1c3216305a0207dd1006d0002080002800000800a6653eb28c3217305c040cfd5006d000410000280000080047047186abc83216305e08119dd006d0004100002800000800a9d2848480321c306160163c5006d0006180002800000800cefa44d392fc126e7cb3c8321a306200963f9006d0006180002800000800cd763cc7d84ab2caae321830640206cf9006d0006180002800000800dfd5d361398daa32173066040cdf9006d00061800028000008003aaf99c2ea1c3216306808119f9006d00061800028000008009c988ea2c03216306a00963f9006d00061800028000008003c24c6ae9b3216306c0206cf9006d0006180002800000800a2fc8567a03218306e040cdf9006d0008200002800000800d3025eaafef7323215307008119f9006d00082000028000008009e74d9263216307200963f9006d0006180002800000800bb4847f7c0321630740206cf9006d00061800028000008009abb46b88032193076040cdf9006d0006180002808000800d875acf78ce617c0", + "f1": "000001846d6f6f66000000106d66686400000000000000020000016c7472616600000010746668640002000000000001000000147466647401000000000000000000003c000001407472756e00000701000000190000018c000000010000003302000000000000010000002901010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000002601010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001401010000000000010000002c01010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001501010000000002516d6461740a0a00000002aff19b5f300832251400234000008000000032183c0a30ea5d604a6be4b7fdf6e91b0327e1de9d1d9a2c32b62032273201e040000003680000014580014000cfe4df7ed7a61fe3eb2e7e3a7c1b33b079c9bb0231ad523212320200820000036800000140000040008af43212320301044000036800000140000040008a803212320402068800036800000140000040008780321132050408d020036800000140000040008532113206080b1844036800000140000040008232113207100d6068836800000140000040008232113208004fa88d036800000140000040007c322432090083f0b1836800000145c0004000d2107e20ef0101c6dc78a580f479cacfcf590c1f3211320a010478d6036800000140000040006c3211320b020688fa836800000140000040006c3211320c0408d03f03680000014000004000683211320d080b184783680000014000004000683211320e100d606883680000014000004000683211320f004fa88d036800000140000040006832123210e083f0b1836800000140000040009cf0322a32110103f2b00368001040001400001400d4af0224e5dd33e63d4fc77b12bf88df15dddfe01e613c5e053212321202067ad4036800104000140000040058321232130408caf803680010400014000004005032123214080b1a3c03680010400014000004005032123215100d626403680010400014000004005032123216004faa8c036800104000140000040050321232170103f2b00368001040001400000400503213321802067ad40368001040001400000400be40", + "f2": "000002b06d6f6f66000000106d666864000000000000000300000298747261660000001074666864000200000000000100000014746664740100000000000000000000550000026c7472756e0000070100000032000002b8000000010000022402000000000000010000001401010000000000010000002201010000000000010000001401010000000000010000002301010000000000010000001301010000000000010000001b01010000000000010000001301010000000000010000001b01010000000000010000001301010000000000010000001f01010000000000010000001301010000000000010000001d01010000000000010000001301010000000000010000002401010000000000010000001301010000000000010000001e01010000000000010000001301010000000000010000001e01010000000000010000001e01010000000000010000001901010000000000010000001301010000000000010000001901010000000000010000001301010000000000010000001701010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001d01010000000000010000001301010000000000010000001a01010000000000010000001301010000000000010000001301010000000000010000001801010000000000010000001901010000000000010000001301010000000000010000001d01010000000000010000001301010000000000010000001901010000000000010000001301010000000000010000002001010000000000010000001301010000000000010000001a01010000000000010000001301010000000006b06d6461740a0a00000002aff19b5f300832950410009b0000020b0002dd2e1aca8acc801a7698a61db1ff5b7609ac6fd04a8b16858a9ccd728af2ee1fe52a3fea4d301d822b65ba987fb685504e5526b2e8b8ba193af1b133438d92caceac434ae35b8c9d2c31199f49ccfaee8b618c4883583beed6f6ee7089bf2b7ab1acc50b926ec22779233a488047f7cc155d7e97b5a5e1dee47f38b383bb3d01a61135c5f148bcf0aa6287d87773c45f2200e50cd367861a99ed5ec96f73352c7a815427baceefbee8710e081a4f217937a734ae996c913e807cf306a0357be06d4a141ab08615851824f76c1d1b1fb6a4944f3fb3fc2005f0e0a8aac7f268250e2844f810527ccbbc91d7f5c738d65033f3aa33b65bc2456d9dbd84ae62d46dbac3601a61c3632dd0e3a0e11b61e35bc7b09d5a3846715d1c918b0a49dd35d177e2dbc10a101890b73afe67e12ce0a0f6e3a965558c4475fbe81a37f6e3a2e083bdd5fd432b40d3e3514e689665c0f50a8a2305fd9d775d9487457f517d5dd82af0ba17acd4208a933b03760161b5192d36394fecfd76a15a368adfea7b24d24f9a867eeff9562b1946d2fdae314ba63cf97f07988eb99edeca01965d6c6ff5c635576f96720efb01e10fb8be9dfcb5178e7869b6d485e751079b2bfc069295b3f8d761215823410e3b85988c8933199231f0fb39d5ea3f326505a94e4b11f67c927ae6bea89d0ebe88a249fa02c889725a8621538c38bb4969156625fec33066394db600da181dad78e54c32123003c080000006d000000280000080009883322030040104000006d00000028000008000dc5c716b21b80f23f7241a59c935ee10321230060208800006d000000280000080008a8032213008040d100006d00000028000008000d0acd6a1000b3a1b44a4622b80e82baab83211300a0811a04006d000000280000080007f3219300c1016308806d00000028000008000ca2deb4873ed7d40c03211300e201ac0d106d000000280000080007632193010009f511a06d00000028000008000c7b4a1fb64d6ad3c90321130120107e16306d000000280000080006c321d30140208f1ac06d00000028080028000c4404b19fda0a826a06505ee3632113016040d11f506d0000002800000800068321b30180811a07e06d00000028000008000dd882ba37519964338ec803211301a1016308f06d00000028000008000683222301c201ac0d106d00000028b80028000dd49032c6e72cb7a612909141d80dcf780803211301e009f511a06d0000002800000800058321c3021c107e16306d00000028000008000db46432f01c10b01dde9ee8a321130220207e56006d0000002800000800086321c3024040cf5a806d00000028b00008000de19b7d31c3118b8b3353ed0321c3026081195f006d00000028000008000c5653f4328cfe1aeebfe9121321730281016347806d00000028000008000dd66b64ce004803211302a201ac4c806d00000028000008000503217302c009f551806d00000028000008000dc99f46e7164803211302e0207e56006d000000280000080005032153030040cf5a806d00000028000008000db629c646032113032081195f006d0000002800000800050321830341016347806d00000028000008000d9cc6c6463e9908032113036201ac4c806d000000280000080006032183038009f551806d00000028000008000e0509f0150119e403211303a0207e56006d00000028000008000303218303c040cf5a806d00000028000008000b9e6825756264fb03211303e081195f006d0000002800000800060321430419016347806c00000028000008000981066283211304220163c5006d000000280000080007e321b3044009eccd006d00000028000008000b38348570c4cb4c2ce6eba321130460207dd1006d000000280000080002032183048040cfd5006d00000028000008000c12a00403585e5553211304a08119dd006d00000028000008000203211304c20163c5006d00000028000008000203216304e009eccd006d00000028000008000b2ed0f61a440321730500207dd1006d00000028000008000afda8d1136378032113052040cfd5006d0000002800000800020321b305408119dd006d00000028b00028000a65d637e083bc86446c7973211305620163c5006d000000280000080002032173058009eccd006d00000028000008000b419bfbd7360403211305a0207dd1006d0000002800000800020321e305c040cfd5006d00000028b80028000b2822ba821299b66a7e57ef0c6583211305e08119dd006d00000028000008000203218306160163c5006d00000028000008000cbc1f1569aed6f803211306200963f9006d0000002800000800082", + "f3": "000001846d6f6f66000000106d66686400000000000000040000016c74726166000000107466686400020000000000010000001474666474010000000000000000000087000001407472756e00000701000000190000018c000000010000003302000000000000010000002901010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000002601010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001301010000000000010000001401010000000000010000002c01010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001401010000000000010000001501010000000002516d6461740a0a00000002aff19b5f300832251400234000008000000032183c0a30ea5d604a6be4b7fdf6e91b0327e1de9d1d9a2c32b62032273201e040000003680000014580014000cfe4df7ed7a61fe3eb2e7e3a7c1b33b079c9bb0231ad523212320200820000036800000140000040008af43212320301044000036800000140000040008a803212320402068800036800000140000040008780321132050408d020036800000140000040008532113206080b1844036800000140000040008232113207100d6068836800000140000040008232113208004fa88d036800000140000040007c322432090083f0b1836800000145c0004000d2107e20ef0101c6dc78a580f479cacfcf590c1f3211320a010478d6036800000140000040006c3211320b020688fa836800000140000040006c3211320c0408d03f03680000014000004000683211320d080b184783680000014000004000683211320e100d606883680000014000004000683211320f004fa88d036800000140000040006832123210e083f0b1836800000140000040009cf0322a32110103f2b00368001040001400001400d4af0224e5dd33e63d4fc77b12bf88df15dddfe01e613c5e053212321202067ad4036800104000140000040058321232130408caf803680010400014000004005032123214080b1a3c03680010400014000004005032123215100d626403680010400014000004005032123216004faa8c036800104000140000040050321232170103f2b00368001040001400000400503213321802067ad40368001040001400000400be40", + "f4": "000002386d6f6f66000000106d666864000000000000000500000220747261660000001074666864000200000000000100000014746664740100000000000000000000a0000001f47472756e000007010000002800000240000000010000020902000000000000010000001d01010000000000010000001501010000000000010000001401010000000000010000002401010000000000010000001301010000000000010000002301010000000000010000001d01010000000000010000001b01010000000000010000001801010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001901010000000000010000001401010000000000010000001701010000000000010000001901010000000000010000001301010000000000010000001901010000000000010000001301010000000000010000001601010000000000010000001301010000000000010000001301010000000000010000003101010000000000010000001301010000000000010000001301010000000000010000002d01010000000000010000001301010000000000010000002801010000000000010000001301010000000000010000001401010000000000010000002201010000000000010000001301010000000000010000001301010000000000010000002301010000000000010000001301010000000000010000001e01010000000000010000001301010000000005d36d6461740a0a00000002aff19b5f300832fa0310009b0082000020b00020dd2b5a94181dbe57910471b2b44e6393dede368a4e90bfb20da6587760f5c17da3e400091f8d227f619ce5f0f1eaa8e0ac0a28df13357428ec6013f14666fb1a5bdf65ce7121305e56d11e5d7eb8f1a2129c4c85dc5a7c8241abb5808d00bad72b22e625a5793d489bd5cb173c6ddf03c81f66d20d3e801e1ff0ac22567f766f1dbf1c6fc81d58e4cfface4b0286491f7322fa6a573b343fb86a83fd361d434077363d40bfec506965337014df2bd867ad3615e770dd02ab18bce869d9c801825195055029cb97f07e2e7fe8e45f2899585386da039947f51fa92c155fbed7db351919809e651dbda2cb2789b56312f61e1d0128674e98a13017355e6ada4bb9cd5e8d64171da47cd48921c166bdb053f7f0ff152965f9405a4f2d0b1b3a5ad18bcf35b37f0bdf664ccf0ce39d863a4b2ef846a4072b6638b15cb37fea04e92d1396eb6fb59c2b3575f9cb3cde67f26505cf70bd1671a31bb9917b9048f1366131a808ed9d2dcc8a7bbb240e5bd3eeb48be87408a289b48de51acefb98712e0c3716bb8c007314679b953c49370b0e653ed401fcb49cf89ed1afad876b13fd0dbc599bb28e2092ee3d50a5336599b48147c43a6a5bbce3b6a2a05af990f7daa839f5a8b3a11d2b5e9caeb8fe1156677b7d4f7774cf16d3196e9021d6b31c0800c73b4787541d2cb1b3c177075fa2f2321b3003c080000006d0008200002800000800cfe49bc877f23c425331321330040104000006d00082000028000008008abc321230060208800006d000000280000080008a8032223008040d100006d00000028b00028000cef0f0ebc3defe87ccbc5985936506a7a6403211300a0811a04006d000000280000080007d3221300c1016308806d00000028b80008000cc0d1d1a060bc018834f4c3b9a88800463321b300e201ac0d106d00000028000008000c82e3e8ad3f178703a5c2032193010009f511a06d00000028000008000c44d557ca4f2efd63b321630120107e16306d00000028000008000be9fe65d0770321130140208f1ac06d000000280000080005c32143016040d11f506d00000028000008000bf474fa2321130180811a07e06d00000028000008000583214301a1016308f06d00000028000008000bef716143211301c201ac0d106d00000028000008000583217301e009f511a06d00000028b80008000bf1c90697630b032123021c107e16306d000000280000080009910321530220207e56006d00000028000832000e04f8d269832173024040cf5a806d00000028000008000d309494a79756032113026081195f006d0000002800000800050321730281016347806d00000028080008000bf2be133f7a4a93211302a201ac4c806d00000028000008000503214302c009f551806d00000028000008000be4474f53211302e0207e56006d000000280000080003032113030040cf5a806d0000002800000800030322f3032081195f006d00000028b80028000c05f2208077e76c8ffd34cf1a160b673d2966a901d49689bb5106ef0973a90321130341016347806d000000280000080003032113036201ac4c806d0000002800000800060322b3038009f551806d00000028b00028000c0f343ccfcb7775cf60cdc2e6af4f42bd82bceb0edbfa14dddd0723211303a0207e56006d00000028000008000603226303c040cf5a806d00000028b00028000c1c2ad0bdbbd36a457d1bfefa5a7051155ca867ccdc03211303e081195f006d0000002800000800060321230419016347806c0000002800000800098353220304220163c5006d00000028b00028000cc8897b5e13136dbf5179c5f1b85cdbe32113044009eccd006d0000002800000800020321130460207dd1006d000000280000080002032213048040cfd5006d00000028b00028000bd95d7dbb9961d3900685bd5857e39d3be3211304a08119dd006d0000002800000800020321c304c20163c5006d00000028b00028000bdc3db4636c609195e2047c03211304e009eccd006d0000002800000800020" + }, + "outcome": { + "outcome": "accept", + "digest": "e96f6662405c2119c2e3d20fc45403804a7e52c9e46773c130a36fae5fbfaff9", + "channel": "c54a2ce987d8b6f5085ed3aaedc11cf5ac94033b937abbebbdafab9bcd8c7540", + "presentation": "on_demand", + "start": 1792886405000000, + "end": 1792886414000000, + "ended": "ended", + "presented_ms": 8000, + "viewable_ms": 8000, + "audible_ms": 8000, + "ad_ms": 0, + "delivery": [ + { + "origin": "https://media.example", + "class": "fetch", + "objects": 6, + "bytes": 9654 + }, + { + "origin": "https://media.example", + "class": "ancillary", + "objects": 2, + "bytes": 744 + } + ], + "repeats": { + "objects": 0, + "bytes": 0 + }, + "startup_ms": 1000, + "stalls": 0, + "stall_ms": 0, + "energy": [ + { + "role": "sink_decode", + "class": "unmetered", + "joules_micro": 0 + }, + { + "role": "display", + "class": "unmetered", + "joules_micro": 0 + } + ], + "claims": [], + "notes": 0, + "attachments": 0 + } +} diff --git a/wai/examples/android-app/settings.gradle.kts b/wai/examples/android-app/settings.gradle.kts new file mode 100644 index 000000000..38df0aa80 --- /dev/null +++ b/wai/examples/android-app/settings.gradle.kts @@ -0,0 +1,27 @@ +// The WAI native sink's Kotlin API in a program: a recorded playback session replayed through +// the sink. The sink is the wai-sink-android project of the open-standards repository; its +// platform-free half (`:core`) is built here from WAI_SINK_ANDROID, by default where it sits +// beside this example in a clone of the repository. An Android app takes the same calls from +// the project's AAR (`:sink`), whose WaiPlayerSession puts them on the player's path. +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } + plugins { + id("org.jetbrains.kotlin.jvm") version "2.3.21" + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "wai-android-app" + +val sdk = file(System.getenv("WAI_SINK_ANDROID") ?: "../../wai-sink-android") +include(":core") +project(":core").projectDir = sdk.resolve("core") +include(":replay") diff --git a/wai/examples/android-app/test/android-app.test.sh b/wai/examples/android-app/test/android-app.test.sh new file mode 100755 index 000000000..cc330bd53 --- /dev/null +++ b/wai/examples/android-app/test/android-app.test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# The android-app example on a JDK 17 host: `wai create` makes the project, the SDK's host +# library is built if it is not, and the replay must put the recorded session through the +# sink's Kotlin API and verify its bundle. +# +# bash wai/examples/android-app/test/android-app.test.sh +# Needs wai on PATH, a JDK 17, Rust, and network access for Gradle and its plugins the first time. +set -uo pipefail +REPO=$(cd "$(dirname "$0")/../../../.." && pwd) +SDK="$REPO/wai/wai-sink-android" +T=$(mktemp -d) +wai create "$T/app" --template android-app > /dev/null || { echo "FAIL wai create"; exit 1; } +. "$SDK/scripts/env.sh" +[ -n "${JAVA_HOME:-}" ] || { echo "FAIL no JDK 17"; exit 1; } +if ! ls "$SDK"/core/build/native/libwai_sink_jni.* > /dev/null 2>&1; then + bash "$SDK/scripts/build-native.sh" host > "$T/native.log" 2>&1 || { tail -30 "$T/native.log"; echo "FAIL the SDK's host library builds"; exit 1; } +fi +# The Gradle the SDK pins, fetched and checked by its own script. +bash "$SDK/scripts/gradle.sh" -Pwai.sink.jvmOnly=true --version > /dev/null || { echo "FAIL the pinned Gradle"; exit 1; } +G="${WAI_SINK_GRADLE_DISTS:-${GRADLE_USER_HOME:-$HOME/.gradle}/wai-sink-dists}/gradle-$WAI_SINK_GRADLE_VERSION/bin/gradle" +cd "$T/app" +s=0 +WAI_SINK_ANDROID="$SDK" "$G" --no-daemon --console=plain -q :replay:run > run.log 2>&1 || s=$? +tail -3 run.log +if [ $s = 0 ] && grep -q 'the bundle verifies' run.log; then echo "ok the recorded session goes through the sink's Kotlin API, and its bundle verifies"; else echo "FAIL the replay (exit $s)"; s=1; fi +exit $s diff --git a/wai/examples/android-app/wai.json b/wai/examples/android-app/wai.json new file mode 100644 index 000000000..94b492067 --- /dev/null +++ b/wai/examples/android-app/wai.json @@ -0,0 +1,5 @@ +{ + "template": "android-app", + "title": "The native sink in Kotlin, for Android", + "summary": "The WAI native sink's Kotlin API, the one an Android app's player session calls, replaying a recorded session and verifying its bundle." +} diff --git a/wai/examples/apple-app/.gitignore b/wai/examples/apple-app/.gitignore new file mode 100644 index 000000000..2d9f16e2d --- /dev/null +++ b/wai/examples/apple-app/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/wai/examples/apple-app/Package.swift b/wai/examples/apple-app/Package.swift new file mode 100644 index 000000000..99564b7cd --- /dev/null +++ b/wai/examples/apple-app/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version:5.9 +// A WAI native sink in an Apple app: a SwiftUI player whose requests go through the sink, and a +// headless replay of a recorded playback session through the same sink. +// +// The sink is the wai-sink-swift package of the open-standards repository, built once with its +// scripts/build-xcframework.sh. WAI_SINK_SWIFT names its directory; by default it is where it +// sits beside this example in a clone of the repository. +import Foundation +import PackageDescription + +let sdk = ProcessInfo.processInfo.environment["WAI_SINK_SWIFT"] ?? "../../wai-sink-swift" + +let package = Package( + name: "WaiApp", + platforms: [.macOS(.v13), .iOS(.v16)], + dependencies: [.package(path: sdk)], + targets: [ + .executableTarget(name: "WaiApp", dependencies: [.product(name: "WaiSink", package: URL(fileURLWithPath: sdk).lastPathComponent)], path: "Sources/WaiApp"), + ] +) diff --git a/wai/examples/apple-app/README.md b/wai/examples/apple-app/README.md new file mode 100644 index 000000000..0e79c9a6f --- /dev/null +++ b/wai/examples/apple-app/README.md @@ -0,0 +1,31 @@ +# A native app on Apple platforms + +**What it shows.** The WAI native sink in a SwiftUI app. The platform's player plays as it +always does; the sink sits on the path of its requests, verifies each playlist against its +receipt and each object against the hash its name states, withholds what does not verify, +signs an acknowledgement of what was received, and at the end signs a bundle the stream's +owner verifies. `--replay` runs a recorded playback session through the same sink with no +window, and checks the bundle. + +**Run it.** The sink is the `wai-sink-swift` package of the WAI repository; build it once +(macOS with Xcode), then point this project at it: + +```sh +wai create my-app --template apple-app +cd /wai/wai-sink-swift && WAI_SINK_SLICES=macos scripts/build-xcframework.sh +cd my-app +export WAI_SINK_SWIFT=/wai/wai-sink-swift +swift run WaiApp --replay sample-session.json +swift run WaiApp +``` + +**What you see.** The replay prints how many playlists and objects went through the sink and +that the bundle verifies. The player opens a window: give it an HLS address (the +`package-and-deliver` example's, under `wai dev`, by default) and press **Play**. + +**Make it yours.** `PlayerApp.swift` is the whole integration: a `WaiSink.Config`, a +`WaiPlayerSession`, and its `player` in a `VideoPlayer`. For a stream its owner seals, set +`requireSigned: true`, and nothing the owner did not sign reaches the decoder. + +**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the +instructions an assistant reads and connects it to WAI's tools. diff --git a/wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift b/wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift new file mode 100644 index 000000000..15162b9fa --- /dev/null +++ b/wai/examples/apple-app/Sources/WaiApp/PlayerApp.swift @@ -0,0 +1,50 @@ +// The player: an HLS address, played by the platform's player through the WAI native sink. The +// sink verifies each playlist against its receipt and each object against the hash its name +// states, withholds what does not verify, signs an acknowledgement of everything received, and, +// when the session ends, a bundle the stream's owner verifies. +import AVKit +import SwiftUI +import WaiSink + +struct PlayerApp: App { + var body: some Scene { + WindowGroup("WAI player") { PlayerView() } + } +} + +struct PlayerView: View { + @State private var address = "http://127.0.0.1:8080/package/hls/classic/floor-v.m3u8" + @State private var player: AVPlayer? + @State private var session: WaiPlayerSession? + @State private var status = "Give an HLS address and press Play." + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + TextField("HLS address", text: $address).textFieldStyle(.roundedBorder) + Button("Play", action: play) + } + VideoPlayer(player: player).frame(minWidth: 480, minHeight: 270) + Text(status).font(.callout).foregroundStyle(.secondary) + } + .padding() + } + + private func play() { + guard let url = URL(string: address) else { status = "Not an address."; return } + do { + // The owner is the key of the collector the bundle is meant for; this player keeps it. + let config = WaiSink.Config(channel: "my-channel", owner: String(repeating: "0", count: 64)) + let s = try WaiPlayerSession(url: url, config: config, attention: WaiFixedAttention()) + s.onEnd = { bundle, ended in + status = "Ended (\(ended.rawValue)): a signed bundle of \(bundle.count) bytes." + } + s.start() + session = s + player = s.player + status = "Playing through the sink." + } catch { + status = "The sink refused: \(error)" + } + } +} diff --git a/wai/examples/apple-app/Sources/WaiApp/Replay.swift b/wai/examples/apple-app/Sources/WaiApp/Replay.swift new file mode 100644 index 000000000..0467ef2c8 --- /dev/null +++ b/wai/examples/apple-app/Sources/WaiApp/Replay.swift @@ -0,0 +1,47 @@ +// A recorded playback session, replayed through the sink call by call: the playlists with their +// receipts, the objects the player received, its observations, its end. The bundle the sink +// signs at the end must verify as the stream's owner verifies it, and state what was recorded. +import Foundation +import WaiSink + +enum Replay { + static func run(path: String) throws -> Bool { + let doc = try JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path))) as! [String: Any] + let objects = doc["objects"] as! [String: String] + func bytes(_ name: Any?) -> Data? { (name as? String).map { hex(objects[$0]!) } } + let sink = try WaiSink(configJSON: doc["config"] as! [String: Any], seed: hex(doc["seed"] as! String), startUs: (doc["start_us"] as! NSNumber).uint64Value) + var bundle = Data() + var verified = 0 + for d in doc["calls"] as! [[String: Any]] { + let t = { (d["t_us"] as! NSNumber).uint64Value } + switch d["call"] as! String { + case "playlist": + _ = try sink.playlist(url: d["url"] as! String, bytes: bytes(d["body"])!, receipt: bytes(d["receipt"])) + verified += 1 + case "object": + _ = try sink.object(meta: d["meta"] as! [String: Any], bytes: bytes(d["body"])!) + verified += 1 + case "event": try sink.event(d["name"] as! String, atUs: t()) + case "observe": _ = try sink.observe(d["obs"] as! [String: Any]) + case "end": bundle = try sink.end(WaiSink.Ended(rawValue: d["ended"] as! String)!, atUs: t()) + default: break + } + } + let out = try WaiSink.verifyBundle(bundle, owner: hex(doc["owner"] as! String), channels: doc["channels"] as! [String]) + let want = doc["outcome"] as! [String: Any] + let ok = out["outcome"] as? String == "accept" && out["digest"] as? String == want["digest"] as? String + print("replay: \(verified) playlists and objects through the sink; the bundle \(ok ? "verifies" : "does NOT verify") as its owner verifies it (\(out["outcome"] ?? "?"), \(out["presented_ms"] ?? "?") ms presented)") + return ok + } + + static func hex(_ s: String) -> Data { + var d = Data(capacity: s.count / 2) + var i = s.startIndex + while i < s.endIndex { + let j = s.index(i, offsetBy: 2) + d.append(UInt8(s[i.. /dev/null || { echo "FAIL wai create"; exit 1; } +if [ ! -d "$SDK/build/WaiFFI.xcframework" ]; then + (cd "$SDK" && WAI_SINK_SLICES=macos bash scripts/build-xcframework.sh > "$T/sdk.log" 2>&1) || { tail -30 "$T/sdk.log"; echo "FAIL the SDK builds"; exit 1; } +fi +cd "$T/app" +export WAI_SINK_SWIFT="$SDK" +swift build > build.log 2>&1 || { tail -40 build.log; echo "FAIL the app builds"; exit 1; } +echo "ok the app builds against the SDK: the SwiftUI player and the replay" +s=0 +out=$(swift run --skip-build WaiApp --replay sample-session.json 2>&1) || s=$? +echo " $out" | tail -2 +if [ $s = 0 ]; then echo "ok the recorded session goes through the sink, and its bundle verifies"; else echo "FAIL the replay (exit $s)"; fi +exit $s diff --git a/wai/examples/apple-app/wai.json b/wai/examples/apple-app/wai.json new file mode 100644 index 000000000..1b6305043 --- /dev/null +++ b/wai/examples/apple-app/wai.json @@ -0,0 +1,5 @@ +{ + "template": "apple-app", + "title": "A native app on Apple platforms", + "summary": "A SwiftUI player whose stream goes through the WAI native sink, and a recorded session replayed through it." +} diff --git a/wai/examples/encoder-ingest/.gitignore b/wai/examples/encoder-ingest/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/wai/examples/encoder-ingest/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/wai/examples/encoder-ingest/Cargo.toml b/wai/examples/encoder-ingest/Cargo.toml new file mode 100644 index 000000000..1e18531ea --- /dev/null +++ b/wai/examples/encoder-ingest/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "encoder-ingest" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +# A project of its own, not a member of any workspace around it. +[workspace] + +[dependencies] +# The relay, the ingest gateway and their wire, from the open-standards repository. +joule-moq-ingest = { git = "https://git.transaction.science/Transaction-Science/open-standards" } +joule-moq-relay = { git = "https://git.transaction.science/Transaction-Science/open-standards" } +joule-moq = { git = "https://git.transaction.science/Transaction-Science/open-standards" } +joule-contrib = { git = "https://git.transaction.science/Transaction-Science/open-standards" } +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "io-util"] } +serde_json = "1" +hex = "0.4" + +# The relay's WebTransport crate, as the repository vendors it (vendor/wtransport/VENDORED.md). +[patch.crates-io] +wtransport = { git = "https://git.transaction.science/Transaction-Science/open-standards" } diff --git a/wai/examples/encoder-ingest/README.md b/wai/examples/encoder-ingest/README.md new file mode 100644 index 000000000..a00c0f2b5 --- /dev/null +++ b/wai/examples/encoder-ingest/README.md @@ -0,0 +1,30 @@ +# Stream from an encoder into a room + +**What it shows.** An encoder's stream entering a room over SRT. The room's authority grants +the encoder's key a publisher's place; an ingest gateway opens an SRT session for that grant +once the encoder's side proves it holds the key, and publishes what arrives as the room's +video and audio tracks. The gateway signs each group's receipt as the first hop; the relay +checks every object against it at its door, and a subscriber checks each one again. + +**Run it.** + +```sh +wai create my-ingest --template encoder-ingest +cd my-ingest +cargo run --release +``` + +It prints an `srt://127.0.0.1:…` address. Point an SRT encoder at it (MPEG-TS with H.264 or +HEVC video and AAC audio, caller mode, the stream ID and passphrase as given). + +**What you see.** A line for the first groups of each track as they arrive, each verified +against the receipt the gateway signed. `cargo run --release -- --synthetic 5` sends five +seconds of a synthetic program itself (shaped like H.264 and AAC, not watchable) and exits 0 +once every group verified. + +**Make it yours.** The gateway also takes RTMP and WHIP, and serves WHEP to players; its +configuration file is `gateway.example.toml` in the repository's `joule-moq-ingest` crate. The +first build fetches the repository and compiles the relay and the gateway. + +**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the +instructions an assistant reads and connects it to WAI's tools. diff --git a/wai/examples/encoder-ingest/src/main.rs b/wai/examples/encoder-ingest/src/main.rs new file mode 100644 index 000000000..d8657642c --- /dev/null +++ b/wai/examples/encoder-ingest/src/main.rs @@ -0,0 +1,226 @@ +//! Stream from an encoder into a room over SRT, and check every object the room carries. +//! +//! cargo run --release # prints the SRT address for your encoder, then waits +//! cargo run --release -- --synthetic 5 # sends five seconds itself, checks them, and exits +//! +//! On this machine, in one process: a relay, an ingest gateway that publishes into the relay a +//! room's tracks from what an encoder sends, and a subscriber. The room's authority grants the +//! encoder's key a publisher's place; the gateway opens an SRT session for that grant when the +//! encoder's side proves it holds the key (live-rooms §10.5.3). Every group the gateway +//! publishes carries a receipt it signs; the relay's door checks each at the hop, and the +//! subscriber checks each object against its receipt again before counting it. + +use ed25519_dalek::SigningKey; +use joule_moq::carriage::ReceiptBundle; +use joule_moq::draft22::{Parameters, StreamHeader, UniEvent}; +use joule_moq::grant_proof; +use joule_moq::receipt::ReceiptVerifier; +use joule_moq::{GroupId, ObjectId}; +use joule_moq_ingest::catalog; +use joule_moq_ingest::room::{build, media}; +use joule_moq_ingest::{GatewayConfig, GatewayServer}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::transport::quic; +use joule_moq_relay::{RelayConfig, RelayServer}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +type Objects = Arc>>>; + +fn now_s() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +fn new_key() -> SigningKey { + SigningKey::generate(&mut rand_core_06::OsRng) +} + +/// One HTTP/1.1 request on a connection of its own: the status, the headers (lowercase +/// names) and the body. +async fn http(addr: std::net::SocketAddr, path: &str, headers: &[(&str, String)]) -> Result<(u16, Vec<(String, String)>, Vec), String> { + let mut s = tokio::net::TcpStream::connect(addr).await.map_err(|e| e.to_string())?; + let mut req = format!("POST {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n"); + for (k, v) in headers { + req += &format!("{k}: {v}\r\n"); + } + s.write_all(format!("{req}\r\n{{}}").as_bytes()).await.map_err(|e| e.to_string())?; + let mut b = Vec::new(); + s.read_to_end(&mut b).await.map_err(|e| e.to_string())?; + let end = b.windows(4).position(|w| w == b"\r\n\r\n").ok_or("no response")?; + let head = String::from_utf8_lossy(&b[..end]).to_string(); + let status = head.split_whitespace().nth(1).and_then(|c| c.parse().ok()).ok_or("no status")?; + let hs = head.lines().skip(1).filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))).collect(); + Ok((status, hs, b[end + 4..].to_vec())) +} + +#[tokio::main] +async fn main() -> std::process::ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let synthetic: Option = args.iter().position(|a| a == "--synthetic").map(|i| args.get(i + 1).and_then(|s| s.parse().ok()).unwrap_or(5)); + match run(synthetic).await { + Ok(true) => std::process::ExitCode::SUCCESS, + Ok(false) => std::process::ExitCode::from(1), + Err(e) => { + eprintln!("encoder-ingest: {e}"); + std::process::ExitCode::from(2) + } + } +} + +async fn run(synthetic: Option) -> Result { + // The room: its authority, the encoder's key, and the gateway's signing key. + let (authority, encoder, gateway_key) = (new_key(), new_key(), new_key()); + let descriptor = build::room(&authority, now_s() * 1_000_000); + let room = descriptor.room_id(); + let owner = hex::encode(encoder.verifying_key().to_bytes()); + + // The relay, on loopback: its door checks every object of the room against the receipts + // the gateway signs. + let mut rc = RelayConfig::loopback(); + rc.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec![hex::encode(room)], trust: vec![hex::encode(gateway_key.verifying_key().to_bytes())], trust_field: None, hold_ms: 10_000 }]; + let relay = RelayServer::start(rc).await.map_err(|e| e.to_string())?; + let relay_addr = relay.quic_addr.ok_or("the relay has no QUIC address")?; + + // The gateway: it publishes into the relay what an encoder sends it over SRT. + let mut gc = GatewayConfig::loopback(relay_addr); + gc.relay.trust_der = vec![relay.identity.chain[0].to_vec()]; + gc.signing.key_seed = Some(gateway_key.to_bytes()); + gc.rooms = vec![joule_moq_ingest::config::RoomConfig { descriptor_path: None, revoked: Vec::new(), descriptor: Some(descriptor.to_bytes()) }]; + gc.contrib.srt.listen = Some("127.0.0.1:0".parse().unwrap()); + let gateway = GatewayServer::start(gc).await.map_err(|e| e.to_string())?; + + // The encoder's grant, and an SRT session for it: the request carries the grant, and a + // proof by the encoder's key over the gateway's challenge. + let grant = build::grant(&descriptor, &authority, &encoder.verifying_key().to_bytes(), 0, now_s() - 60, now_s() + 3600); + let bearer = ("authorization", format!("Bearer {}", grant_proof::base64url(&grant.to_bytes()))); + let (status, hs, _) = http(gateway.addr, "/srt", std::slice::from_ref(&bearer)).await?; + let challenge = hs.iter().find(|(k, _)| k == "wai-room-challenge").and_then(|(_, v)| grant_proof::from_base64url(v)).and_then(|b| <[u8; 32]>::try_from(b).ok()); + let Some(challenge) = challenge else { return Err(format!("the gateway gave no challenge ({status})")) }; + let context = grant_proof::context("POST", &gateway.addr.to_string(), "/srt"); + let proof = grant_proof::GrantProof::sign(grant.digest(), &encoder, challenge, &context).ok_or("no proof")?; + let (status, _, body) = http(gateway.addr, "/srt", &[bearer, ("wai-room-proof", grant_proof::base64url(&proof.to_bytes()))]).await?; + if status != 201 { + return Err(format!("POST /srt: {status} {}", String::from_utf8_lossy(&body))); + } + let s: serde_json::Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?; + let srt_addr = gateway.srt_addr.ok_or("the gateway has no SRT listener")?; + let url = format!( + "srt://{srt_addr}?streamid={}&passphrase={}&pbkeylen={}&latency={}", + s["stream_id"].as_str().unwrap_or(""), + s["passphrase"].as_str().unwrap_or(""), + s["pbkeylen"].as_u64().unwrap_or(16), + s["latency_ms"].as_u64().unwrap_or(120) + ); + println!("room {}…, encoder key {}…", &hex::encode(room)[..16], &owner[..16]); + println!("send MPEG-TS (H.264 or HEVC video, AAC audio) over SRT to:\n {url}"); + + // The subscriber: the encoder's video and audio tracks and their receipts tracks. + let ep = quic::client_endpoint("127.0.0.1:0".parse().unwrap(), vec![relay.identity.chain[0].clone()], vec![joule_moq::draft22::ALPN.to_vec()], None).map_err(|e| e.to_string())?; + let conn = quic::connect(&ep, relay_addr, "localhost").await.map_err(|e| e.to_string())?; + let client = Arc::new(Client::connect(Arc::new(conn), ClientOptions::default()).await?); + let mut tracks = Vec::new(); + let mut held = Vec::new(); + for (kind, name) in [(media::VIDEO, "video"), (media::AUDIO, "audio")] { + let ns = catalog::media_namespace(&room, &owner, kind); + let (m, mtx, mrx) = client.subscribe(ns.clone(), joule_moq::TrackName::from_str("0"), Parameters::new()).await?; + let (r, rtx, rrx) = client.subscribe(ns, catalog::receipts_name("0"), Parameters::new()).await?; + tracks.push((name, m.track_alias, r.track_alias)); + held.push((mtx, rtx, mrx, rrx)); + } + let objects: Objects = Arc::default(); + { + let (c, o) = (client.clone(), objects.clone()); + tokio::spawn(async move { + loop { + let Some(mut r) = c.next_stream(Duration::from_secs(1)).await else { continue }; + let o = o.clone(); + tokio::spawn(async move { + let Ok(StreamHeader::Subgroup(sh)) = r.start().await else { return }; + while let Ok(Some(e)) = r.next().await { + if let UniEvent::SubgroupObject(x) = e + && !x.payload.is_empty() + { + o.lock().unwrap().insert((sh.track_alias, sh.group_id, x.object_id), x.payload.to_vec()); + } + } + }); + } + }); + } + + // What the encoder sends: a synthetic program (the shape of H.264 and AAC, not decodable + // media), or a real encoder's stream, sent to the address above. + let sender = synthetic.map(|secs| { + let url = url.clone(); + tokio::spawn(async move { + let u = joule_contrib::cli::Url::parse(&url)?; + let link = joule_contrib::cli::srt_link(&u, joule_contrib::srt::Role::Send).await?; + let r = joule_contrib::cli::pace(&link, &joule_contrib::cli::synthetic(secs, 2000), 1500).await; + link.close(); + r + }) + }); + + // Check each group as its object and its receipts arrive. + let v = ReceiptVerifier::with_anchors(vec![gateway_key.verifying_key()]); + let mut done: BTreeSet<(u64, u64)> = BTreeSet::new(); + let mut counts: BTreeMap<&str, (u64, u64)> = BTreeMap::new(); + let deadline = synthetic.map(|s| tokio::time::Instant::now() + Duration::from_secs(s + 15)); + let mut sent = false; + loop { + tokio::time::sleep(Duration::from_millis(500)).await; + let objs = objects.lock().unwrap().clone(); + for (name, media_alias, receipts_alias) in &tracks { + for ((alias, g, o), payload) in objs.iter().filter(|((a, _, _), _)| a == media_alias) { + if done.contains(&(*alias, *g)) { + continue; + } + let Some(bundle) = objs.iter().find(|((a, rg, _), _)| a == receipts_alias && rg == g).map(|(_, b)| b) else { continue }; + done.insert((*alias, *g)); + let c = counts.entry(name).or_default(); + let ok = ReceiptBundle::from_payload(bundle).ok().and_then(|b| { + let vg = v.verify_group(&b.group).ok()?; + let r = b.objects.get(*o as usize)?; + r.check_moqt(b.group.track_alias, GroupId(*g), ObjectId(*o)).ok()?; + v.verify_payload(payload, r, &vg).ok() + }); + if ok.is_some() { + c.0 += 1; + if c.0 <= 3 || c.0 % 30 == 0 { + println!("{name} group {g}: {} bytes, verified against the receipt the gateway signed", payload.len()); + } + } else { + c.1 += 1; + println!("{name} group {g}: REFUSED, it does not verify against its receipt"); + } + } + } + if let Some(s) = &sender + && s.is_finished() + && !sent + { + sent = true; + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + if sent || deadline.is_some_and(|d| tokio::time::Instant::now() > d) { + break; + } + } + if let Some(s) = sender { + match s.await { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("the synthetic encoder: {e}")), + Err(e) => return Err(e.to_string()), + } + } + let (v_ok, v_bad) = counts.get("video").copied().unwrap_or_default(); + let (a_ok, a_bad) = counts.get("audio").copied().unwrap_or_default(); + println!("verified {v_ok} video and {a_ok} audio groups; refused {}", v_bad + a_bad); + drop(held); + gateway.shutdown().await; + relay.shutdown().await; + Ok(v_ok > 0 && a_ok > 0 && v_bad + a_bad == 0) +} diff --git a/wai/examples/encoder-ingest/test/encoder-ingest.test.sh b/wai/examples/encoder-ingest/test/encoder-ingest.test.sh new file mode 100755 index 000000000..f6e02cd09 --- /dev/null +++ b/wai/examples/encoder-ingest/test/encoder-ingest.test.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# The encoder-ingest example: `wai create` makes the project, its git dependencies are pointed +# at this checkout, and `--synthetic 4` sends four seconds over SRT into the room; every group +# of both tracks must verify against the gateway's receipts at the subscriber (exit 0). +# +# bash wai/examples/encoder-ingest/test/encoder-ingest.test.sh +# Needs wai on PATH and cargo. +set -uo pipefail +REPO=$(cd "$(dirname "$0")/../../../.." && pwd) +T=$(mktemp -d) +wai create "$T/ingest" --template encoder-ingest > /dev/null || { echo "FAIL wai create"; exit 1; } +cd "$T/ingest" +python3 - "$REPO" Cargo.toml <<'PY' +import sys +repo, p = sys.argv[1], sys.argv[2] +crates = {"joule-moq-ingest": "joule-code/joule-code-rs/crates/joule-moq-ingest", "joule-moq-relay": "joule-code/joule-code-rs/crates/joule-moq-relay", + "joule-moq": "joule-code/joule-code-rs/crates/joule-moq", "joule-contrib": "joule-code/joule-code-rs/crates/joule-contrib", + "wtransport": "joule-code/joule-code-rs/vendor/wtransport"} +git = 'git = "https://git.transaction.science/Transaction-Science/open-standards"' +out = [] +for line in open(p).read().splitlines(): + name = line.split("=")[0].strip() + if name in crates and git in line: + line = line.replace(git, f'path = "{repo}/{crates[name]}"') + out.append(line) +open(p, "w").write("\n".join(out) + "\n") +assert git not in open(p).read(), "every git dependency is pointed at the checkout" +PY +cargo build --release --quiet 2> build.log || { tail -30 build.log; echo "FAIL the example builds"; exit 1; } +s=0 +timeout 120 ./target/release/encoder-ingest --synthetic 4 > run.log 2>&1 || s=$? +cat run.log +if [ $s = 0 ]; then echo "ok every group sent over SRT verified against the gateway's receipts at the subscriber"; else echo "FAIL the synthetic encoder's groups (exit $s)"; fi +exit $s diff --git a/wai/examples/encoder-ingest/wai.json b/wai/examples/encoder-ingest/wai.json new file mode 100644 index 000000000..1341393d3 --- /dev/null +++ b/wai/examples/encoder-ingest/wai.json @@ -0,0 +1,5 @@ +{ + "template": "encoder-ingest", + "title": "Stream from an encoder into a room", + "summary": "An SRT encoder's stream published into a room by an ingest gateway, every group signed at the first hop and checked at the relay and the subscriber." +} diff --git a/wai/examples/messaging/.gitignore b/wai/examples/messaging/.gitignore new file mode 100644 index 000000000..7a3455e10 --- /dev/null +++ b/wai/examples/messaging/.gitignore @@ -0,0 +1 @@ +.wai/ diff --git a/wai/examples/messaging/README.md b/wai/examples/messaging/README.md new file mode 100644 index 000000000..8682bb057 --- /dev/null +++ b/wai/examples/messaging/README.md @@ -0,0 +1,31 @@ +# Messaging and a call + +**What it shows.** A conversation and a call, side by side. In the conversation each member +makes its own key in its tab, keys into one end-to-end encrypted group through the service's +log, seals each message before it leaves the tab, and opens and verifies each one it reads: +the service stores ciphertext and signed records, never the text. A call is placed with a +proof of the caller's key; the invitation rings in the callee's inbox, checked before it +rings; answered, both enter a room with the grants the call came with. One that rings out is +signed as missed by the room's clock. + +**Run it.** + +```sh +wai create my-chat --template messaging +cd my-chat +wai dev +``` + +Open `http://127.0.0.1:8080/` in two browsers (two tabs of one browser share nothing, so +that works too). + +**What you see.** On the left, join the conversation in each and write: each side reads the +other's messages, with threads, reactions, edits and retractions. On the right, paste one +tab's key (under **Your key**) into the other's **Keys to call** and press **Call**: the +first tab's inbox rings, and answering opens the call, camera and microphone on both sides. + +**Make it yours.** The conversation's API is under `/msg-api/` and the call's under +`/room-api/`; both pages take `?service=` to point at them. + +**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the +instructions an assistant reads and connects it to WAI's tools. diff --git a/wai/examples/messaging/index.html b/wai/examples/messaging/index.html new file mode 100644 index 000000000..72ff7c243 --- /dev/null +++ b/wai/examples/messaging/index.html @@ -0,0 +1,24 @@ + + + + + +Messages and calls + + + +

Messages and calls

+
+ + + + +
+ + diff --git a/wai/examples/messaging/test/messaging.test.mjs b/wai/examples/messaging/test/messaging.test.mjs new file mode 100644 index 000000000..74e14e9ce --- /dev/null +++ b/wai/examples/messaging/test/messaging.test.mjs @@ -0,0 +1,62 @@ +// The messaging example, as a person runs it: `wai create` makes the project, `wai dev` runs a +// conversation beside a call room, and two browsers open its page. Both join the conversation +// and each opens what the other sealed; then one calls the other, the callee's inbox rings, +// it answers, and each receives and decodes the other's camera and microphone. +// +// node wai/examples/messaging/test/messaging.test.mjs +import { check, create, dev, dump, finish, frame, launch, open, STEP, tmpdir, until } from "../../../quickstart/test/lib.mjs"; + +const out = tmpdir("messaging"); +const dir = await create("messaging", out); +const d = await dev(dir); +const browser = await launch(); +const pages = []; +try { + const people = []; + for (const name of ["alice", "bob"]) { + const p = await open(browser, `${d.url}/`, name); + pages.push(p); + const m = await frame(p, "/room/messages.html"); + await until(`${name}: the messages page ready`, STEP, () => m.evaluate(() => !!(window.msgTest && window.msgTest.join)), 200); + const phase = await m.evaluate(() => window.msgTest.join()); + if (phase !== "member") throw new Error(`${name} did not join: ${phase}`); + const c = await frame(p, "/room/call.html"); + await until(`${name}: the call page ready`, STEP, () => c.evaluate(() => window.callTest && window.callTest.state.phase === "ready"), 200); + people.push({ p, m, c, key: await c.evaluate(() => window.callTest.state.me) }); + } + const [A, B] = people; + await until("both keyed into one group of two", STEP, async () => { + const s = await Promise.all([A, B].map((x) => x.m.evaluate(() => window.msgTest.state))); + return s.every((x) => x.members.length === 2 && x.epoch && x.epoch.epoch === s[0].epoch.epoch); + }, 300); + await A.m.evaluate(() => window.msgTest.send("hello from alice")); + await B.m.evaluate(() => window.msgTest.send("hello from bob")); + const texts = (v) => v.messages.filter((m) => m.outcome === "accepted").map((m) => m.text).sort().join("|"); + for (const x of [A, B]) { + const got = await until(`${x.p.name} opens both messages`, STEP, async () => { const t = texts(await x.m.evaluate(() => window.msgTest.view())); return t === "hello from alice|hello from bob" && t; }, 300); + check(`${x.p.name}'s page opens and verifies what each member sealed`, true, got); + } + // Alice calls Bob; his inbox rings; he answers. + await A.c.evaluate((k) => { window.callTest.place([k]).catch((e) => { window.callTest.state.error = String(e); }); }, B.key); + const ringing = await until("bob's inbox rings", STEP, async () => { const s = await B.c.evaluate(() => window.callTest.state); return s.inbox.find((x) => x.state === "ringing") && s; }, 300); + check("the callee's inbox holds the invitation, checked with its call before it rings", ringing.inbox[0].check.ok && ringing.inbox[0].check.caller === A.key); + await B.c.evaluate(() => { window.callTest.accept().catch((e) => { window.callTest.state.error = String(e); }); }); + const room = (x) => frame(x.p, "/room/index.html"); + const principal = async (x) => (await room(x)).evaluate(() => window.roomTest.snapshot().me.principal); + for (const x of [A, B]) { + await until(`${x.p.name}'s call publishes`, STEP * 2, async () => (await (await room(x)).evaluate(() => window.roomTest && window.roomTest.state.phase)) === "publishing", 500); + } + const [pa, pb] = [await principal(A), await principal(B)]; + for (const [x, other] of [[A, pb], [B, pa]]) { + const t = await until(`${x.p.name} decodes the other's call media`, STEP * 2, async () => { const s = await (await room(x)).evaluate(() => window.roomTest.snapshot()); const t = s.subs[other]; return t && t.video.decoded >= 20 && t.audio.decoded >= 30 && t; }, 500); + check(`${x.p.name}'s call carries the other's camera and microphone, decoded`, true, `${t.video.decoded} video frames, ${t.audio.decoded} audio frames`); + } + check("caller and callee publish under the grants their call came with", pa === A.key && pb === B.key); +} catch (e) { + check("the example ran to its end", false, (e && e.stack || String(e)).split("\n").slice(0, 3).join(" | ")); +} finally { + dump(pages, out); + await browser.close().catch(() => {}); + await d.stop(); +} +finish("messaging"); diff --git a/wai/examples/messaging/wai.json b/wai/examples/messaging/wai.json new file mode 100644 index 000000000..f5fa0d606 --- /dev/null +++ b/wai/examples/messaging/wai.json @@ -0,0 +1,6 @@ +{ + "template": "messaging", + "title": "Messaging and a call", + "summary": "An end-to-end encrypted conversation, and a call placed, rung and answered in a room.", + "dev": { "site": ".", "mode": "call", "messaging": true } +} diff --git a/wai/examples/package-and-deliver/.gitignore b/wai/examples/package-and-deliver/.gitignore new file mode 100644 index 000000000..22393500e --- /dev/null +++ b/wai/examples/package-and-deliver/.gitignore @@ -0,0 +1,3 @@ +.wai/ +video-base-01.y4m +package/ diff --git a/wai/examples/package-and-deliver/README.md b/wai/examples/package-and-deliver/README.md new file mode 100644 index 000000000..47e2bb607 --- /dev/null +++ b/wai/examples/package-and-deliver/README.md @@ -0,0 +1,29 @@ +# Package a title and play it + +**What it shows.** A title packed by policy: a WAI base of `wai.video.int_motion` objects and, +beside it, a floor in a standard codec (AV1 in CMAF) that any player plays, with HLS and DASH +playlists and a MoQ catalog. The whole package is sealed in a signed receipt, and verified: +every object, every playlist, the receipt. Then the page plays the floor with the browser's +own decoder. + +**Run it.** + +```sh +wai create my-title --template package-and-deliver +cd my-title +sh pack.sh +wai dev +``` + +Open `http://127.0.0.1:8080/`. + +**What you see.** `pack.sh` prints what it packed and `wai-pack verify` checks it. The page +plays the 64×36 test clip from `package/hls/classic/floor-v.m3u8`, segment by segment. + +**Make it yours.** `policy.toml` names the source, the ladder and the classes of sink. Point +`source` at your own clip (Y4M video, WAV audio) and supply its AV1 floor in `floor/`; sign +with a key of your own, not `test-key.hex`, which is a published test key. `wai-pack` alone +prints its commands, live packaging among them. + +**With a coding assistant.** Run `wai setup-agent` in this directory: it writes the +instructions an assistant reads and connects it to WAI's tools. diff --git a/wai/examples/package-and-deliver/floor/v.obu b/wai/examples/package-and-deliver/floor/v.obu new file mode 100644 index 0000000000000000000000000000000000000000..40171fdd0491805527aa3e7ec54a6b0a6b6cadaa GIT binary patch literal 6546 zcmeI0c{o)2AIHy(!DMSj%*JGBQ&a9iR1#BUDHq++Lbhb7L|RCRxw&L5)lGy#l1gQX zlx%I>vV~9)uJmgoid5qMzURz#obLa>e}2!?^Yrw7e?Fh@`}>_UXI_1kAvrk+f{53L zQe0uOUNn&crGS6RL&SEdp0yq(w>&b@p?&c8@XK+mWSV|k5EdLg_pCvh_Nwz)oTg}F zkGmTEp0jkT>inYmRhT0y-6v8Wg#I1i98n)pMvkTVbY*05*L_sZrjBnOs+)Q!p{_dZ z!Sjh@r;GbdGy+-2_Bsyiu=V;Bd16I2|JfV*=hdgRH#H{?Kl8{q-)Bk)qi&lr_ONy8 zu?2FGT29kHo+?-ENgjRY@nj%BF<*XNAxNeXoCx@v4>b%Z-fW$4>nV%8sK)~ZOqfI< zVGHUa>AyLc_UR&~IxIsZ^D*7E^Bm?HMJ;M~Th@^Sd@!U2EMtjsJhrhKe zer-E2Xz+A#Y&2Y9yZ2?QZs4fHnpKlxH?DzrZM>t#`F{|RgbY}VDBI^IiShKD+M$gO zMMy6Vo=H*|$&n<<9b2=%l?NPEcs7}OYEaTuh@Hifouzr@H5iqV*&nvPoY zIIR0M602>rD)GZFNC*{pK6K83A+fG38;n6)bj0D%ZQX=F)%2PDjao1!8+iC4f?bb= z%X|eE4c40+?nQ+43h5ElStw2yiyZAmxZq@WF6tOg4%bm1VKcG~II8esaW=H){N|Hq zuAqjII2`)ycg}nBJPH_^u(2@OJqXe+fwZBD4x5$Cm1eIpwtt1Yvkn=8A~tLy4C&4n z6x~yP*s(U+G%a9D?hXD-rZQj)qHHGxV_hFM57-tJh_+dg6vick$xuyv z(=79DV6os;WNI2R5m^%nQm{ib&wnU>i&##4Uceb1U);462b>PupM_~|-@SL+fO$y?GLx(pDgDe+r+L9Cyc?J0lXt0YgipaXu z^v55_0jtCAZh}uol)nDEdGm=M%6=h*NGA*40wtOzU_OAlK;!E6RRxc(0ha-9CCW5l z-L9w2?n~k?1CI`SkrcYHp3(K>yYB>h@F7#rVXf4pqnVYUi3V@SEoVIQ;k6Xh@x?8b z`6I$R9Sz|pTuAg`HxpehPmJmTPZi!JTuJ1e-TQ1dI&L>^p;9Z$Q9*>gxP{J?T9hDX z#e@SfJ3>Ct_Iit-@lcgZpWQ30ehl~nqmm5LyxrKD0IDosu)5T{N~iHOR2lx|Tvu1S%FE}?EHV60;lBqH?E4MMZ~1m?T-00pwTza^xdy8`!Lt@c2ZbzguSi9?1DEX zx0yym`TAz@Cmz-3nyu<}ic6cX%RFaY`+XqRJcy@0`kycB7dzcuxihWok84|6 zcHX|{#&W;sv7oUxy+_Tx*Qq)$&2h0sfV2AXSUKxXgC{|Mzo?RUm0EvRzI65_00e>++m5rShmu>+i&x=-rNynrq`0ms)RA z^_n$t5B6H}Yccek!M?h`&jxuY@$8!v4pa^vcsn)+?9eQkI%9MuYtvr_0~E9q$GtJg z>qHYzyN$W68IZh@nt9e1F8>YpatSD+;;}ajh)fs3r-vsqo2QBr7N(un#v)_veY+eX6SJ#OEDX2WpkQ<3ZoS`av`56RLYo6b z1}p+sp;$E!;u%ErIiL+9a>X=I#lVUYC9EnG3;L(JXCcQx7qhC+x&Az!YPg7xju*iy zW|Ep1R3Tz4qGvmMgp!xmY_+I9jrNF871Ki(BdSom!K^6T!yrh2 zOUioxGYtDKgEt^8$zYdnI8F*ExMAyY^5sjz7~6!30>$i&L2e^eht9u( zrf9@#g}JBp4Gz7_>CTnY?wV$>SNnt8v>^+xF_u%8XMKPs?6UsJbm!2Lp-kmen}`m_ zki*Nb6!GJs+ihH}*Rw7JFSAN{`j~FKvrDQ*!>dWrgg;$lFjs4RSfD?WVno1@xgwZr~WRLE^fQ`RYTSP?CK<5VcdDM z?O6@s@S0Q7_H!Sf9t_n9d-KXwJ9*>U$mdeYmLnRM=Bv7vx4kf6GWlmc*=3oJ=R_BI zD1VvGCTM%xCS|~M1DEa3SKq30iQh_RXN9yxYgT!c<*gkZ{6}@J=Qp0C-2!UwVJ?ly2Bn zMhiHvcI)`T1gjxKcikLvf7*iDn1iJ=m7JOfZp6OXOU{hG5#PtEA4>|*QCLJPjf3{6 zc&D5)wq7K!)9vQuO8VkZCvrrrj&xCoD&1f>@VnhCg1qo^1jh_r#rpH zndy$-5=NirEE&lu%wTg}Qm>UvP}h4OJY*NNw}IBo^h?leEmS8%)k`gptYgp5FRTiZ zb=pE~CvHVE=?(_kVQ=$@=uMbty1uFb=)S&%Jh*-31&fmcI}v+YCe%Jabi8+GV1} zxVuH<_do>?TjirP9{tg*OKAPS;M7ldhvnvbk-I)B zjAJPn6>%(uh`(Ss3jHaqbk!ouh6dLB(`+-8;a0q-DRFxO+uA3kh~hVR+9&~?gQG0L z@PEN8E(WjAr>*j{S?!OTL7TrQbm(j!9_F@l>+||6GJfrB+>H*#5f^e!v&9#RLY}C) zdsY5LnPJw3ClwzrT$H9dJc + + + + +A packed title + + + +

A packed title

+

Run sh pack.sh first, then reload.

+ + + + diff --git a/wai/examples/package-and-deliver/pack.sh b/wai/examples/package-and-deliver/pack.sh new file mode 100755 index 000000000..5633b1b21 --- /dev/null +++ b/wai/examples/package-and-deliver/pack.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Package the sample title and check the package: +# 1. the title: a 64x36 test clip, made by wai-pack itself (deterministic); +# 2. pack it by policy.toml: a WAI base (wai.video.int_motion objects) and the AV1 floor +# supplied in floor/, as CMAF with HLS and DASH playlists and a MoQ catalog, the whole +# sealed in package/package.receipt.json with the key in test-key.hex; +# 3. verify the package: every object, every playlist and the receipt. +# test-key.hex is a published test key (32 bytes of 0x5a), so the receipt proves nothing about +# who packed it; for your own titles, use a key of your own (64 hexadecimal digits, kept secret). +set -eu +cd "$(dirname "$0")" +wai-pack fixture gen video-base-01 -o video-base-01.y4m +rm -rf package +wai-pack pack --policy policy.toml --out package --key test-key.hex +wai-pack verify package +echo "packed: open the page wai dev serves to play it" diff --git a/wai/examples/package-and-deliver/player.js b/wai/examples/package-and-deliver/player.js new file mode 100644 index 000000000..352579c62 --- /dev/null +++ b/wai/examples/package-and-deliver/player.js @@ -0,0 +1,30 @@ +// Play a packed title's floor in a