diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml index 5113c0fc6..455bd4010 100644 --- a/.github/workflows/wai-catalogue.yml +++ b/.github/workflows/wai-catalogue.yml @@ -139,7 +139,7 @@ jobs: ran "$RUNNER_TEMP/lib.log" cargo test --locked --no-default-features --features quickstart --bin wai --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli.log" ran "$RUNNER_TEMP/cli.log" - cargo test --locked --no-default-features --features quickstart,generate,score,staged,video_int,audio_int,splat_int --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-full.log" + cargo test --locked --no-default-features --features quickstart,generate,score,staged,video_int,audio_int,splat_int,exact_upscale,exact_tonemap --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-full.log" ran "$RUNNER_TEMP/cli-full.log" cargo test --locked --no-default-features --features quickstart,app --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-app.log" ran "$RUNNER_TEMP/cli-app.log" diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 08444be4d..ead55e12d 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -1271,7 +1271,7 @@ jobs: --test live_events --test live_room --test capture_take --test live_compose --test live_record \ --test timed_text --test access_services --test live_metering --test energy_steering \ --test live_moderation --test live_ads --test ad_presentation --test live_provenance \ - --test synthesis --test exact_upscale --test ai_usage --test contribution --test settlement \ + --test synthesis --test exact_upscale --test cli_derive --test ai_usage --test contribution --test settlement \ --test browser_sink --test takedown --test influence --test direction --test co_performance \ --test age_assurance --test bearer_receipts --test exploration --test co_watch \ --test telemetry_aggregate --test exact_tonemap --test splat_exact --test live_watermark \ @@ -1847,6 +1847,7 @@ jobs: (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib int_sr::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib exact_upscale::) (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test exact_upscale) + (cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test cli_derive) python3 -I live-conformance/upscale_verify.py live-conformance/upscale # Robustness evidence for soft bindings (extensions/robustness-evidence.md): the # reference's unit tests (gamma is the rounded power, rates are exact and reduced, every diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 7d7fc481f..4899968cb 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -12,6 +12,17 @@ wire format may change and such changes are recorded here. never reached: it does not call `extract`), and `elliptic-curve` 0.14, which leaves no `crypto-bigint` 0.5 in this workspace. A caller that passes its own `c2pa::Signer` builds against 0.91. +### `wai upscale` and `wai tonemap`: an exact derivation from the command line, with its record and its check + +- **`wai upscale run`** (`media.upscale`) upscales an 8-bit RGB PNG under a prior, and with `--record` signs the record + and writes the input's envelope it names. **`wai upscale verify`** (`upscale.verify`) recomputes the record from them + under `require-proof`, and holds the picture to it. +- **`wai tonemap run`** (`media.tonemap`) renders a still's gain map at the headrooms named, and with `--record` signs + the record. **`wai tonemap verify`** (`tonemap.verify`) recomputes it from its envelope, and holds the renditions to it. +- The library functions under them, `exact_upscale::make` and `exact_tonemap::make`, return the record's body for a + caller to sign. `exact_upscale` now also needs `png_still` and `receipt_claims`. The catalogue's verbs gain `upscale` + and `tonemap`. + ### The route record states what a hop delivered by its count and root, so a hop may deliver any number of objects - **Route record version 3** ([extensions/route-record.md](extensions/route-record.md) §3.4, §5.2, §6, §6.1), on a diff --git a/wai/catalogue/README.md b/wai/catalogue/README.md index 60b6e4793..72c805c77 100644 --- a/wai/catalogue/README.md +++ b/wai/catalogue/README.md @@ -14,7 +14,7 @@ An operation is named `noun.verb`: lowercase ASCII, a singular noun, and a verb rule N3's closed list (create, list, show, update, delete, check, verify, run, build, export, import, start, stop, join, leave, grant, invite, revoke, rotate, tail, search, test, load, encode, decode, inspect, wrap, unwrap, fetch, generate, promote, rollback, lock, upgrade, setup, guide, -stream), never a near-synonym (`get`, `describe` and `info` are `show`; `keygen`, `init` and +stream, upscale, tonemap), never a near-synonym (`get`, `describe` and `info` are `show`; `keygen`, `init` and `new` are `create`). A command a person already types keeps its spelling as the operation's command line: `wai hello` is `toolchain.test`, `wai stage refine` is `stage.encode`. Its entry (format `wai.ops/1`) carries: diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json index 2fc7d9518..26d555a0b 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -1000,6 +1000,392 @@ "sdk": "wai.media.generate" } }, + { + "id": "media.upscale", + "summary": "Upscale a PNG exactly, by an integer network under a pinned prior, with a signed record a verifier recomputes", + "cli": "wai upscale run", + "aliases": [], + "usage": [ + "wai upscale run --prior [--derivation sr2|sr4] -o ", + " [--record --envelope --key ] [--issued-at ] [--json]", + " an 8-bit RGB PNG; --record signs what exact-upscale §7 recomputes from --envelope and the prior" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "An 8-bit RGB PNG.", + "x-cli": { + "positional": true + } + }, + "prior": { + "type": "string", + "format": "path", + "description": "The WSR1 prior.", + "x-cli": { + "flag": "--prior" + } + }, + "derivation": { + "type": "string", + "description": "sr2 (the default) or sr4.", + "x-cli": { + "flag": "--derivation" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The picture, a PNG.", + "x-cli": { + "flag": "-o" + } + }, + "record": { + "type": "string", + "format": "path", + "description": "The signed upscale record.", + "x-cli": { + "flag": "--record" + } + }, + "envelope": { + "type": "string", + "format": "path", + "description": "The WAI1 of the input, which the record names.", + "x-cli": { + "flag": "--envelope" + } + }, + "key": { + "type": "string", + "description": "The record's signing seed: 64 hexadecimal digits, or a file holding them.", + "x-cli": { + "flag": "--key" + } + } + }, + "required": [ + "path", + "prior", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "width", + "height", + "subpixels", + "files" + ], + "description": "What `wai upscale run` or `wai tonemap run` made: the subpixels it would bill, each file it wrote with its digest, and the record's link hash" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "exact_upscale" + ] + }, + "names": { + "cli": "wai media upscale", + "mcp": null, + "c": "wai_media_upscale", + "rust": "wai::media::upscale", + "sdk": "wai.media.upscale" + } + }, + { + "id": "upscale.verify", + "summary": "An upscale record recomputed from its input and its prior, and the picture held to it", + "cli": "wai upscale verify", + "aliases": [], + "usage": [ + "wai upscale verify --envelope --prior [--picture ]", + " the record's outcome as JSON (extensions/exact-upscale.md §7, under require-proof); exit 1 when it is refused" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "record": { + "type": "string", + "format": "path", + "description": "The upscale record.", + "x-cli": { + "positional": true + } + }, + "envelope": { + "type": "string", + "format": "path", + "description": "The WAI1 of the input, which the record names.", + "x-cli": { + "flag": "--envelope" + } + }, + "prior": { + "type": "string", + "format": "path", + "description": "The WSR1 prior the record names.", + "x-cli": { + "flag": "--prior" + } + }, + "picture": { + "type": "string", + "format": "path", + "description": "The picture: its samples are held to the record's output.", + "x-cli": { + "flag": "--picture" + } + } + }, + "required": [ + "record", + "envelope", + "prior" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "outcome" + ], + "description": "accept, with the record's link hash and the subpixels it states; or refuse, with the reason and where" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "exact_upscale" + ] + }, + "names": { + "cli": "wai upscale verify", + "mcp": null, + "c": "wai_upscale_verify", + "rust": "wai::upscale::verify", + "sdk": "wai.upscale.verify" + } + }, + { + "id": "media.tonemap", + "summary": "Render a still's gain map exactly at the headrooms named, with a signed record a verifier recomputes", + "cli": "wai tonemap run", + "aliases": [], + "usage": [ + "wai tonemap run --headrooms --out [--input-class ]", + " [--record --key ] [--issued-at ] [--json]", + " a WAI2 of a PNG base and its gain map; a headroom is in 1/10000 of a stop; each rendition is RGB16" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-an-unnamed-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "path": { + "type": "string", + "format": "path", + "description": "A WAI2 of a PNG base and its gain map.", + "x-cli": { + "positional": true + } + }, + "headrooms": { + "type": "string", + "description": "The headrooms, in 1/10000 of a stop, joined by commas.", + "x-cli": { + "flag": "--headrooms" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "The directory the renditions are written in.", + "x-cli": { + "flag": "--out" + } + }, + "input_class": { + "type": "string", + "description": "The input's class (captured).", + "x-cli": { + "flag": "--input-class" + } + }, + "record": { + "type": "string", + "format": "path", + "description": "The signed rendition record.", + "x-cli": { + "flag": "--record" + } + }, + "key": { + "type": "string", + "description": "The record's signing seed: 64 hexadecimal digits, or a file holding them.", + "x-cli": { + "flag": "--key" + } + } + }, + "required": [ + "path", + "headrooms", + "out" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "width", + "height", + "subpixels", + "files" + ], + "description": "What `wai upscale run` or `wai tonemap run` made: the subpixels it would bill, each file it wrote with its digest, and the record's link hash" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": true, + "needs": { + "all": [ + "exact_tonemap" + ] + }, + "names": { + "cli": "wai media tonemap", + "mcp": null, + "c": "wai_media_tonemap", + "rust": "wai::media::tonemap", + "sdk": "wai.media.tonemap" + } + }, + { + "id": "tonemap.verify", + "summary": "A rendition record recomputed from its envelope, and the renditions held to it", + "cli": "wai tonemap verify", + "aliases": [], + "usage": [ + "wai tonemap verify --envelope [--renditions ]", + " the record's outcome as JSON (extensions/exact-tonemap.md §8, under require-proof); exit 1 when it is refused" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "stable", + "input": { + "type": "object", + "properties": { + "record": { + "type": "string", + "format": "path", + "description": "The rendition record.", + "x-cli": { + "positional": true + } + }, + "envelope": { + "type": "string", + "format": "path", + "description": "The WAI2 the record names.", + "x-cli": { + "flag": "--envelope" + } + }, + "renditions": { + "type": "string", + "format": "path", + "description": "The directory `wai tonemap run` wrote: each rendition is held to its picture.", + "x-cli": { + "flag": "--renditions" + } + } + }, + "required": [ + "record", + "envelope" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "outcome" + ], + "description": "accept, with the record's link hash and the subpixels it states; or refuse, with the reason and where" + }, + "stream": null, + "since": "0.3.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "exact_tonemap" + ] + }, + "names": { + "cli": "wai tonemap verify", + "mcp": null, + "c": "wai_tonemap_verify", + "rust": "wai::tonemap::verify", + "sdk": "wai.tonemap.verify" + } + }, { "id": "score.build", "summary": "A WSCR score from integer note fields", diff --git a/wai/extensions/exact-tonemap.md b/wai/extensions/exact-tonemap.md index b62167efb..228fb3cec 100644 --- a/wai/extensions/exact-tonemap.md +++ b/wai/extensions/exact-tonemap.md @@ -601,6 +601,9 @@ for the break (`companion-only-as-capability`, `companion-only-as-rendition`, ## 10. Conformance +`wai tonemap run` renders a still's gain map and writes its record, and `wai tonemap verify` checks +them under `require-proof`. + [`live-conformance/tonemap/`](../live-conformance/tonemap/) holds the corpus, written by `wai_tonemap_vectors gen` from fixed seeds and from the integer video corpus: `vectors.json` (with the arithmetic of §3 and §5.4 at stated points and over whole ranges: `L2`, `E2`, the diff --git a/wai/extensions/exact-upscale.md b/wai/extensions/exact-upscale.md index 131d12021..fa59c2a8b 100644 --- a/wai/extensions/exact-upscale.md +++ b/wai/extensions/exact-upscale.md @@ -427,6 +427,9 @@ states an output its derivation does not give. ## 9. Conformance +`wai upscale run` makes a picture and its record, and `wai upscale verify` checks them under +`require-proof`. + [`live-conformance/upscale/`](../live-conformance/upscale/) holds the corpus: `vectors.json`, written from fixed seeds by `wai_upscale_vectors gen`, the priors it reads in `priors/`, each pinned by its SHA-256 in the generator, and `sha256.json`, the digest of every derivation's diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index f7d008a10..124f05aa2 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -452,7 +452,7 @@ synthesis_mark = ["synthesis", "video_int", "generate", "png_still"] # frames of a span that were transmitted, derived exactly, edited by an unprovable step or # generated, each held to its origin claim (synthesis). Its claims are synthesis's, its residuals # staged's. SHA-256, BLAKE3 and Ed25519 only; no new dependency. -exact_upscale = ["synthesis", "staged"] +exact_upscale = ["synthesis", "staged", "png_still", "receipt_claims"] # Deep redundancy for Opus decoded exactly (extensions/opus-dred): `wai.audio.opus.dred.int` — the # DRED extension of draft-ietf-mlcodec-opus-dred-07 found in an Opus packet's padding # (draft-ietf-mlcodec-opus-extension-06's iterator, RFC 6716's framing and range decoder), its @@ -1783,6 +1783,13 @@ required-features = ["generate"] # `wai generate` and `wai score build|render` run as a user runs them, against the # committed generate vectors and score-conformance/. +# `wai upscale` and `wai tonemap`, end to end: what they write is what each extension's verifier +# proves. +[[test]] +name = "cli_derive" +path = "tests/cli_derive.rs" +required-features = ["exact_upscale", "exact_tonemap"] + [[test]] name = "cli_generate_score" path = "tests/cli_generate_score.rs" diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index c5a08d106..1f6f66169 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -56,6 +56,10 @@ mod generate; #[path = "wai_cli/score.rs"] mod score; +#[cfg(any(feature = "exact_upscale", feature = "exact_tonemap"))] +#[path = "wai_cli/derive.rs"] +mod derive; + #[cfg(feature = "platform_encode")] #[path = "wai_cli/encode.rs"] mod platform_encode_cli; diff --git a/wai/wai-rs/src/bin/wai_cli/derive.rs b/wai/wai-rs/src/bin/wai_cli/derive.rs new file mode 100644 index 000000000..adb66980b --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/derive.rs @@ -0,0 +1,205 @@ +//! `wai upscale run` and `wai tonemap run`: an exact derivation of a picture, with the signed record +//! a verifier recomputes, and their `verify` (extensions/exact-upscale.md, extensions/exact-tonemap.md). +//! +//! ```text +//! wai upscale run --prior [--derivation sr2|sr4] -o +//! [--record --envelope --key ] [--issued-at ] [--json] +//! wai upscale verify --envelope --prior [--picture ] +//! wai tonemap run --headrooms --out [--input-class ] +//! [--record --key ] [--issued-at ] [--json] +//! wai tonemap verify --envelope [--renditions ] +//! ``` +//! +//! Each writes what it derives, and with `--record` signs the record of it. `--json` prints the +//! picture's size, the subpixels, each file with its SHA-256 and the record's link hash. + +use super::opt; +use ed25519_dalek::SigningKey; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +fn write(path: &str, bytes: &[u8]) -> Result<(), String> { + crate::out::write(path, bytes).map_err(|e| format!("{path}: {e}")) +} + +fn read(path: &str) -> Result, String> { + std::fs::read(path).map_err(|e| format!("{path}: {e}")) +} + +/// The positional argument: the first that is neither a flag nor a flag's value. +fn path_of<'a>(args: &'a [String], flags: &[&str]) -> Option<&'a str> { + let mut i = 0; + while i < args.len() { + if flags.contains(&args[i].as_str()) { + i += 2; + } else if args[i].starts_with('-') { + i += 1; + } else { + return Some(&args[i]); + } + } + None +} + +/// `--key`: 64 lowercase hexadecimal digits, or a file holding them. +fn signing_key(args: &[String]) -> Result { + let s = opt(args, "--key").ok_or("--record needs --key")?; + let text = if s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()) { s.to_string() } else { String::from_utf8_lossy(&read(s)?).trim().to_string() }; + let seed: Vec = (text.len() == 64 && text.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))) + .then(|| (0..32).map(|i| u8::from_str_radix(&text[2 * i..2 * i + 2], 16).expect("hexadecimal")).collect()) + .ok_or("--key is 64 lowercase hexadecimal digits, or a file holding them")?; + Ok(SigningKey::from_bytes(&seed.try_into().expect("32 bytes"))) +} + +fn issued_at(args: &[String]) -> String { + opt(args, "--issued-at").map(String::from).unwrap_or_else(|| { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + wai::claim_receipt::rfc3339_utc(now) + }) +} + +/// What a derivation wrote, printed for a person or, with `--json`, as its output's schema. +fn report(args: &[String], width: u64, height: u64, subpixels: u64, files: &[(String, Vec)], record: Option<[u8; 32]>) { + let digest = |b: &[u8]| Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect::(); + let link = record.map(|l| l.iter().map(|x| format!("{x:02x}")).collect::()); + if args.iter().any(|a| a == "--json") { + let listed: Vec = files.iter().map(|(n, b)| json!({"file": n, "sha256": digest(b)})).collect(); + println!("{}", json!({"width": width, "height": height, "subpixels": subpixels, "files": listed, "record": link})); + return; + } + for (n, b) in files { + println!("wrote {n} ({} bytes), SHA-256 {}", b.len(), digest(b)); + } + println!("{width}×{height}, {subpixels} subpixels"); + if let Some(l) = link { + println!("record {l}"); + } +} + +#[cfg(feature = "exact_upscale")] +pub fn upscale(args: &[String]) -> Result<(), String> { + use wai::exact_upscale as ex; + const FLAGS: [&str; 7] = ["--prior", "--derivation", "-o", "--record", "--envelope", "--key", "--issued-at"]; + let input = path_of(args, &FLAGS).ok_or("wai upscale run --prior -o ")?; + let prior = read(opt(args, "--prior").ok_or("--prior is the WSR1 prior")?)?; + let out = opt(args, "-o").ok_or("-o is the picture")?; + let derivation = match opt(args, "--derivation").unwrap_or("sr2") { + "sr2" => wai::int_sr::SR2, + "sr4" => wai::int_sr::SR4, + other => return Err(format!("--derivation {other}: sr2 or sr4")), + }; + let (w, h, rgb) = wai::png_still::decode(&read(input)?).map_err(|e| format!("{input}: {e}"))?; + let made = ex::make(&rgb, w, h, derivation, &prior, &issued_at(args))?; + let mut files = vec![(out.to_string(), made.picture.clone())]; + let mut link = None; + if let Some(path) = opt(args, "--record") { + let envelope = opt(args, "--envelope").ok_or("--record needs --envelope: the record names the input's envelope, which a verifier needs")?; + let record = ex::sign(made.body.clone(), &signing_key(args)?); + let text = wai::jcs::jcs(&record).map_err(|e| format!("the record's canonical form: {e:?}"))?; + files.push((path.to_string(), text.into_bytes())); + files.push((envelope.to_string(), made.envelope.clone())); + link = Some(ex::link_hash(&record)); + } + for (n, b) in &files { + write(n, b)?; + } + report(args, made.width, made.height, made.width * made.height * 3, &files, link); + Ok(()) +} + +/// A verification's outcome, printed as JSON; whether it accepted. +fn outcome(refused: Option<(String, Value)>, link: Option<[u8; 32]>, subpixels: u64) -> bool { + let hex = |b: [u8; 32]| b.iter().map(|x| format!("{x:02x}")).collect::(); + let accepted = refused.is_none(); + let v = match refused { + Some((reason, at)) => json!({"outcome": "refuse", "reason": reason, "at": at}), + None => json!({"outcome": "accept", "record": link.map(hex), "subpixels": subpixels}), + }; + println!("{}", serde_json::to_string_pretty(&v).expect("JSON")); + accepted +} + +/// `wai upscale verify`: exact-upscale §7 under `require-proof`, and the picture held to the output. +#[cfg(feature = "exact_upscale")] +pub fn upscale_verify(args: &[String]) -> Result { + use wai::exact_upscale as ex; + let path = path_of(args, &["--envelope", "--prior", "--picture"]).ok_or("wai upscale verify --envelope --prior ")?; + let text = String::from_utf8_lossy(&read(path)?).into_owned(); + let envelope = read(opt(args, "--envelope").ok_or("--envelope is the WAI1 of the input")?)?; + let prior = read(opt(args, "--prior").ok_or("--prior is the WSR1 prior")?)?; + let refuse = |reason: &str, at: &str| Ok(outcome(Some((reason.to_string(), json!([at]))), None, 0)); + let Ok(env) = wai::container::Wai::from_bytes(&envelope) else { return refuse("input_unreadable", "envelope") }; + if env.capability() != wai::codecs::CAP_IMAGE_PNG { + return refuse("input_unreadable", "envelope"); + } + let Ok((_, _, rgb)) = wai::png_still::decode(&env.payload) else { return refuse("input_unreadable", "envelope") }; + let hash = wai::jwp_hash::content_hash(&envelope); + let case = ex::Case { + upscales: vec![text.clone()], + objects: vec![envelope], + decoded: vec![(hash, rgb)], + priors: vec![prior], + policy: ex::Policy { require_proof: true, ..Default::default() }, + ..Default::default() + }; + if let ex::Outcome::Refuse { at, reason } = ex::verify(&case) { + return Ok(outcome(Some((reason.code().to_string(), at.to_json())), None, 0)); + } + let u = ex::read_upscale(&text).map_err(|r| format!("{path}: {}", r.code()))?; + let (w, h) = u.output_size(); + if let Some(picture) = opt(args, "--picture") { + let held = wai::png_still::decode(&read(picture)?).ok().is_some_and(|(pw, ph, s)| (u64::from(pw), u64::from(ph)) == (w, h) && Sha256::digest(&s).as_slice() == u.output); + if !held { + return refuse("picture_mismatch", "picture"); + } + } + Ok(outcome(None, Some(u.link), w * h * 3)) +} + +/// `wai tonemap verify`: exact-tonemap §8 under `require-proof`, and each rendition held to its picture. +#[cfg(feature = "exact_tonemap")] +pub fn tonemap_verify(args: &[String]) -> Result { + use wai::exact_tonemap as tm; + let path = path_of(args, &["--envelope", "--renditions"]).ok_or("wai tonemap verify --envelope ")?; + let text = String::from_utf8_lossy(&read(path)?).into_owned(); + let mut held = tm::Held::default(); + held.hold_envelope(read(opt(args, "--envelope").ok_or("--envelope is the WAI2 the record names")?)?); + if let tm::Outcome::Refuse { at, reason } = tm::verify(std::slice::from_ref(&text), &held, tm::Policy::RequireProof) { + return Ok(outcome(Some((reason.code().to_string(), Value::Array(at))), None, 0)); + } + let r = tm::read_record(&text).map_err(|e| format!("{path}: {}", e.code()))?; + if let Some(dir) = opt(args, "--renditions") { + for (k, p) in r.pictures.iter().enumerate() { + let held = std::fs::read(format!("{dir}/rendition-h{}.rgb16", p.headroom)).is_ok_and(|b| tm::digest(&b) == p.output); + if !held { + return Ok(outcome(Some(("rendition_mismatch".to_string(), json!(["renditions", k]))), None, 0)); + } + } + } + Ok(outcome(None, Some(r.link), r.pictures.len() as u64 * r.width * r.height * 3)) +} + +#[cfg(feature = "exact_tonemap")] +pub fn tonemap(args: &[String]) -> Result<(), String> { + use wai::exact_tonemap as tm; + const FLAGS: [&str; 6] = ["--headrooms", "--out", "--input-class", "--record", "--key", "--issued-at"]; + let input = path_of(args, &FLAGS).ok_or("wai tonemap run --headrooms --out ")?; + let dir = opt(args, "--out").ok_or("--out is the directory the renditions are written in")?; + let headrooms = opt(args, "--headrooms").ok_or("--headrooms are the headrooms, in 1/10000 of a stop")?; + let headrooms: Vec = headrooms.split(',').map(|h| h.parse().map_err(|_| format!("--headrooms: {h} is not an integer"))).collect::>()?; + let made = tm::make(&read(input)?, &headrooms, opt(args, "--input-class").unwrap_or("captured"), &issued_at(args))?; + let mut files: Vec<(String, Vec)> = made.renditions.iter().map(|(h, b)| (format!("{dir}/rendition-h{h}.rgb16"), b.clone())).collect(); + let mut link = None; + if let Some(path) = opt(args, "--record") { + let record = tm::sign(made.body.clone(), &signing_key(args)?); + let text = wai::jcs::jcs(&record).map_err(|e| format!("the record's canonical form: {e:?}"))?; + files.push((path.to_string(), text.into_bytes())); + link = Some(tm::link_hash(&record)); + } + crate::out::create_dir_all(dir).map_err(|e| format!("{dir}: {e}"))?; + for (n, b) in &files { + write(n, b)?; + } + report(args, made.width, made.height, made.renditions.len() as u64 * made.width * made.height * 3, &files, link); + Ok(()) +} diff --git a/wai/wai-rs/src/bin/wai_cli/door.rs b/wai/wai-rs/src/bin/wai_cli/door.rs index 23f762ed9..2db236106 100644 --- a/wai/wai-rs/src/bin/wai_cli/door.rs +++ b/wai/wai-rs/src/bin/wai_cli/door.rs @@ -550,6 +550,16 @@ gated!(media_generate, "media.generate", cfg(feature = "generate"), |a| { crate::generate::main(a)?; Ok(true) }); +gated!(media_upscale, "media.upscale", cfg(feature = "exact_upscale"), |a| { + crate::derive::upscale(a)?; + Ok(true) +}); +gated!(media_tonemap, "media.tonemap", cfg(feature = "exact_tonemap"), |a| { + crate::derive::tonemap(a)?; + Ok(true) +}); +gated!(upscale_verify, "upscale.verify", cfg(feature = "exact_upscale"), |a| Ok(crate::derive::upscale_verify(a)?)); +gated!(tonemap_verify, "tonemap.verify", cfg(feature = "exact_tonemap"), |a| Ok(crate::derive::tonemap_verify(a)?)); gated!(score_build, "score.build", cfg(feature = "score"), |a| { crate::score::main(&with(&["build"], a))?; Ok(true) @@ -620,6 +630,10 @@ pub const IMPLS: &[Impl] = &[ Impl { id: "media.encode", run: media_encode, json: Json::Text }, Impl { id: "media.decode", run: media_decode, json: Json::Text }, Impl { id: "media.generate", run: media_generate, json: Json::Own }, + Impl { id: "media.upscale", run: media_upscale, json: Json::Own }, + Impl { id: "media.tonemap", run: media_tonemap, json: Json::Own }, + Impl { id: "upscale.verify", run: upscale_verify, json: Json::Own }, + Impl { id: "tonemap.verify", run: tonemap_verify, json: Json::Own }, Impl { id: "score.build", run: score_build, json: Json::Own }, Impl { id: "score.decode", run: score_render, json: Json::Own }, Impl { id: "encoder.list", run: encoder_list, json: Json::Text }, diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 7afd746cf..6bc790d70 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -42,7 +42,8 @@ pub const ENVELOPE: &str = "envelope/1"; pub const VERBS: &[&str] = &[ "create", "list", "show", "update", "delete", "check", "verify", "run", "build", "export", "import", "start", "stop", "join", "leave", "grant", "invite", "revoke", "rotate", "tail", "search", "test", "load", "encode", "decode", "inspect", - "wrap", "unwrap", "fetch", "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream", + "wrap", "unwrap", "fetch", "generate", "promote", "rollback", "lock", "upgrade", "setup", "guide", "stream", "upscale", + "tonemap", ]; /// Verbs that are another verb's near-synonym, with the verb to use: an id never ends in one. @@ -289,6 +290,8 @@ const S_TEMPLATES: &str = r#"{"type":"array","items":{"type":"object","required" const S_DEV_CHECK: &str = r#"{"type":"object","required":["ready","seconds","steps","checks","origin"]}"#; const S_DEV_READY: &str = r#"{"type":"object","required":["ready"],"properties":{"ready":{"type":"boolean"},"origin":{"type":"string"}}}"#; const S_GENERATE: &str = r#"{"type":"object","description":"The picture's or instruction object's digests, as `wai generate --json` prints them"}"#; +const S_DERIVED: &str = r#"{"type":"object","required":["width","height","subpixels","files"],"description":"What `wai upscale run` or `wai tonemap run` made: the subpixels it would bill, each file it wrote with its digest, and the record's link hash"}"#; +const S_DERIVED_VERIFY: &str = r#"{"type":"object","required":["outcome"],"description":"accept, with the record's link hash and the subpixels it states; or refuse, with the reason and where"}"#; const S_SCORE: &str = r#"{"type":"object","description":"The score's digests, as `wai score build|render --json` print them"}"#; const S_BREAKING: &str = r#"{"type":"object","required":["against","findings"],"description":"Each finding's id, category, what changed and whether the CHANGELOG acknowledges it"}"#; const S_CONFORM: &str = r#"{"type":"object","required":["conformant","cases","passed","failed"]}"#; @@ -507,6 +510,80 @@ encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof needs: Needs::All(&["generate"]), ..OP }, + Op { + id: "media.upscale", + summary: "Upscale a PNG exactly, by an integer network under a pinned prior, with a signed record a verifier recomputes", + cli: Some("upscale run"), + usage: "upscale run --prior [--derivation sr2|sr4] -o + [--record --envelope --key ] [--issued-at ] [--json] + an 8-bit RGB PNG; --record signs what exact-upscale §7 recomputes from --envelope and the prior", + effect: Effect::WritesProject, + input: &[ + pos("path", Ty::Path, true, "An 8-bit RGB PNG."), + flag("prior", "--prior", Ty::Path, true, "The WSR1 prior."), + flag("derivation", "--derivation", Ty::String, false, "sr2 (the default) or sr4."), + flag("out", "-o", Ty::Path, true, "The picture, a PNG."), + flag("record", "--record", Ty::Path, false, "The signed upscale record."), + flag("envelope", "--envelope", Ty::Path, false, "The WAI1 of the input, which the record names."), + flag("key", "--key", Ty::String, false, "The record's signing seed: 64 hexadecimal digits, or a file holding them."), + ], + output: Output::Json(S_DERIVED), + receipt: true, + needs: Needs::All(&["exact_upscale"]), + ..OP + }, + Op { + id: "upscale.verify", + summary: "An upscale record recomputed from its input and its prior, and the picture held to it", + cli: Some("upscale verify"), + usage: "upscale verify --envelope --prior [--picture ] + the record's outcome as JSON (extensions/exact-upscale.md §7, under require-proof); exit 1 when it is refused", + input: &[ + pos("record", Ty::Path, true, "The upscale record."), + flag("envelope", "--envelope", Ty::Path, true, "The WAI1 of the input, which the record names."), + flag("prior", "--prior", Ty::Path, true, "The WSR1 prior the record names."), + flag("picture", "--picture", Ty::Path, false, "The picture: its samples are held to the record's output."), + ], + output: Output::Json(S_DERIVED_VERIFY), + needs: Needs::All(&["exact_upscale"]), + ..OP + }, + Op { + id: "media.tonemap", + summary: "Render a still's gain map exactly at the headrooms named, with a signed record a verifier recomputes", + cli: Some("tonemap run"), + usage: "tonemap run --headrooms --out [--input-class ] + [--record --key ] [--issued-at ] [--json] + a WAI2 of a PNG base and its gain map; a headroom is in 1/10000 of a stop; each rendition is RGB16", + effect: Effect::WritesProject, + input: &[ + pos("path", Ty::Path, true, "A WAI2 of a PNG base and its gain map."), + flag("headrooms", "--headrooms", Ty::String, true, "The headrooms, in 1/10000 of a stop, joined by commas."), + flag("out", "--out", Ty::Path, true, "The directory the renditions are written in."), + flag("input_class", "--input-class", Ty::String, false, "The input's class (captured)."), + flag("record", "--record", Ty::Path, false, "The signed rendition record."), + flag("key", "--key", Ty::String, false, "The record's signing seed: 64 hexadecimal digits, or a file holding them."), + ], + output: Output::Json(S_DERIVED), + receipt: true, + needs: Needs::All(&["exact_tonemap"]), + ..OP + }, + Op { + id: "tonemap.verify", + summary: "A rendition record recomputed from its envelope, and the renditions held to it", + cli: Some("tonemap verify"), + usage: "tonemap verify --envelope [--renditions ] + the record's outcome as JSON (extensions/exact-tonemap.md §8, under require-proof); exit 1 when it is refused", + input: &[ + pos("record", Ty::Path, true, "The rendition record."), + flag("envelope", "--envelope", Ty::Path, true, "The WAI2 the record names."), + flag("renditions", "--renditions", Ty::Path, false, "The directory `wai tonemap run` wrote: each rendition is held to its picture."), + ], + output: Output::Json(S_DERIVED_VERIFY), + needs: Needs::All(&["exact_tonemap"]), + ..OP + }, Op { id: "score.build", summary: "A WSCR score from integer note fields", @@ -1819,6 +1896,8 @@ pub fn feature_on(feature: &str) -> Option { "video_manifest" => cfg!(feature = "video_manifest"), "key_discovery" => cfg!(feature = "key_discovery"), "generate" => cfg!(feature = "generate"), + "exact_upscale" => cfg!(feature = "exact_upscale"), + "exact_tonemap" => cfg!(feature = "exact_tonemap"), "score" => cfg!(feature = "score"), "staged" => cfg!(feature = "staged"), "video_int" => cfg!(feature = "video_int"), diff --git a/wai/wai-rs/src/exact_tonemap.rs b/wai/wai-rs/src/exact_tonemap.rs index ef6c6cd23..2af3c31ab 100644 --- a/wai/wai-rs/src/exact_tonemap.rs +++ b/wai/wai-rs/src/exact_tonemap.rs @@ -1300,6 +1300,68 @@ pub fn read_record(text: &str) -> Result { // ─────────────────────────────── §8 verification ─────────────────────────────── +/// What a tone mapping of a still makes: each headroom's rendition, ascending, and the body of +/// their record (§7.1), which [`sign`] signs. +#[derive(Clone, Debug, PartialEq)] +pub struct Rendered { + pub width: u64, + pub height: u64, + /// (headroom, the rendition's `RGB16` bytes). + pub renditions: Vec<(u64, Vec)>, + pub body: Value, +} + +/// Render a `WAI2` of a still's PNG (rendition 0) and its gain map at `headrooms` (§4.4), and write +/// its record's body (§7.1): its input of `input_class`, its own class the least that allows (§7.2). +pub fn make(envelope: &[u8], headrooms: &[u64], input_class: &str, issued_at: &str) -> Result { + use crate::container::{companion_indices, WaiMulti}; + if !envelope.starts_with(crate::container::MAGIC_V2) { + return Err("not a WAI2 envelope: a gain map is a companion, which only WAI2 carries".into()); + } + let env = WaiMulti::from_bytes(envelope).map_err(|e| e.to_string())?; + env.check_component_roles().map_err(|e| e.code().to_string())?; + let entries = &env.manifest.renditions; + if !entries.first().is_some_and(|m| m.is_selectable() && m.capability == crate::codecs::CAP_IMAGE_PNG) { + return Err(format!("rendition 0 is not a selectable {}", crate::codecs::CAP_IMAGE_PNG)); + } + let index = companion_indices(entries, 0).into_iter().find(|&i| entries.get(i).is_some_and(|m| m.is_companion() && m.capability == CAP_GAINMAP)).ok_or_else(|| format!("no {CAP_GAINMAP} companion of rendition 0"))?; + let companion = &env.renditions[index]; + let gain = read_gainmap(companion).map_err(|r| format!("the gain map: {}", r.code()))?; + let (w, h, base) = crate::png_still::decode(&env.renditions[0]).map_err(|e| format!("the base: {e}"))?; + if u64::from(w) > MAX_SIDE || u64::from(h) > MAX_SIDE { + return Err(format!("the base is {w}×{h}, past {MAX_SIDE} on a side")); + } + let mut ordered = headrooms.to_vec(); + ordered.sort_unstable(); + ordered.dedup(); + if ordered.is_empty() || ordered.len() > MAX_PICTURES || ordered.len() != headrooms.len() { + return Err(format!("1 to {MAX_PICTURES} distinct headrooms")); + } + if let Some(h) = ordered.iter().find(|h| **h > MAX_HEADROOM) { + return Err(format!("the headroom {h} is past {MAX_HEADROOM}")); + } + let rank = CLASSES.iter().position(|c| *c == input_class).ok_or_else(|| format!("the class {input_class} is not one of {CLASSES:?}"))?; + let mut renditions = Vec::with_capacity(ordered.len()); + for headroom in ordered { + let out = gain.render(&base, w, h, headroom).map_err(|r| format!("headroom {headroom}: {}", r.code()))?; + renditions.push((headroom, rgb16_bytes(&out))); + } + let object = hex(&crate::jwp_hash::content_hash(envelope)); + let input = hex(&digest(&base)); + let pictures: Vec = renditions.iter().map(|(headroom, b)| json!({"frame": 0, "headroom": headroom, "input": input, "output": hex(&digest(b))})).collect(); + let body = json!({ + "kind": KIND, + "derivation": GAINMAP, + "base": {"object": object, "rendition": 0, "width": w, "height": h}, + "companion": {"index": index, "sha256": hex(&digest(companion))}, + "pictures": pictures, + "class": CLASSES[rank.max(1)], + "inputs": [{"content_hash": object, "origin": Value::Null, "class": input_class}], + "issued_at": issued_at, + }); + Ok(Rendered { width: u64::from(w), height: u64::from(h), renditions, body }) +} + /// The policy of §8.1. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Policy { diff --git a/wai/wai-rs/src/exact_upscale.rs b/wai/wai-rs/src/exact_upscale.rs index 5e8e4dc45..1ff6fa4f9 100644 --- a/wai/wai-rs/src/exact_upscale.rs +++ b/wai/wai-rs/src/exact_upscale.rs @@ -490,6 +490,47 @@ pub fn encode_residual(upscaled: &[u8], target: &[u8], w: u32, h: u32, q: u32) - // §7 Verification // --------------------------------------------------------------------------------------- +/// What an upscale makes: the input's envelope, the output's samples and the picture they are, and +/// the body of their record (§4.2), which [`sign`] signs. +#[derive(Clone, Debug, PartialEq)] +pub struct Upscaled { + /// The `WAI1` of the input's PNG, the object the record names. + pub envelope: Vec, + pub samples: Vec, + pub picture: Vec, + pub width: u64, + pub height: u64, + pub body: Value, +} + +/// Upscale an `RGB8` picture of `w × h` by `derivation` under `prior` (§3), and write its record's +/// body (§4.2): no residual and no target. A prior that does not load, or a picture past §3.3's +/// limits, has no output. +pub fn make(rgb: &[u8], w: u32, h: u32, derivation: &str, prior: &[u8], issued_at: &str) -> Result { + use crate::container::{Conditioning, Manifest, ModelRequirement, Wai}; + let loaded = int_sr::load(prior).map_err(|e| format!("the prior does not load: {e:?}"))?; + let samples = int_sr::derive(derivation, &loaded, rgb, w as usize, h as usize).map_err(|e| format!("{derivation}: {e:?}"))?; + let f = int_sr::factor(derivation).ok_or_else(|| format!("{derivation} is not a derivation"))? as u32; + let (width, height) = (w * f, h * f); + let manifest = Manifest::new("image", "replicate", ModelRequirement::new(crate::codecs::CAP_IMAGE_PNG), Conditioning::new("png")); + let envelope = Wai::new(manifest, crate::png_still::encode(rgb, w, h)).to_bytes().map_err(|e| format!("the envelope: {e}"))?; + let digest = hex(&sha256(&[&samples])); + let body = json!({ + "kind": "wai-upscale", + "object": hex(&crate::jwp_hash::content_hash(&envelope)), + "input": {"width": w, "height": h, "samples": hex(&sha256(&[rgb]))}, + "derivation": derivation, + "prior": hex(&sha256(&[prior])), + "upscaled": digest, + "residual": Value::Null, + "output": digest, + "target": Value::Null, + "issued_at": issued_at, + }); + let picture = crate::png_still::encode(&samples, width, height); + Ok(Upscaled { envelope, samples, picture, width: width as u64, height: height as u64, body }) +} + /// A verifier's policy (§7.1): synthesis's two members and `upscale`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Policy { diff --git a/wai/wai-rs/tests/cli_derive.rs b/wai/wai-rs/tests/cli_derive.rs new file mode 100644 index 000000000..479011cae --- /dev/null +++ b/wai/wai-rs/tests/cli_derive.rs @@ -0,0 +1,150 @@ +//! `wai upscale` and `wai tonemap` end to end: what each runs and writes is what its extension's verifier +//! proves under `require-proof`, from the files it wrote alone. +#![cfg(all(feature = "exact_upscale", feature = "exact_tonemap"))] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use wai::exact_tonemap as tm; +use wai::exact_upscale as ex; + +const SEED: &str = "0707070707070707070707070707070707070707070707070707070707070707"; + +fn wai_in(d: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(d).args(args).output().expect("wai runs") +} + +fn scratch(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("wai-cli-derive-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn ok(o: &Output) -> &Output { + assert!(o.status.success(), "{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + o +} + +fn prior_path() -> String { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/upscale/priors/generic.wsr").to_str().unwrap().to_string() +} + +#[test] +fn an_upscale_writes_what_exact_upscale_proves() { + let d = scratch("upscale"); + let rgb: Vec = (0..8u32 * 6).flat_map(|i| [(i * 7 % 256) as u8, (i * 3 % 256) as u8, (i % 256) as u8]).collect(); + std::fs::write(d.join("in.png"), wai::png_still::encode(&rgb, 8, 6)).unwrap(); + let prior = prior_path(); + let o = wai_in(&d, &["upscale", "run", "in.png", "--prior", &prior, "-o", "out.png", "--record", "r.json", "--envelope", "in.wai", "--key", SEED, "--json"]); + let v: serde_json::Value = serde_json::from_slice(&ok(&o).stdout).unwrap(); + let v = &v["data"]; + assert_eq!((&v["width"], &v["height"], &v["subpixels"]), (&serde_json::json!(16), &serde_json::json!(12), &serde_json::json!(16 * 12 * 3))); + + let text = std::fs::read_to_string(d.join("r.json")).unwrap(); + let envelope = std::fs::read(d.join("in.wai")).unwrap(); + let (pw, ph, samples) = wai::png_still::decode(&std::fs::read(d.join("out.png")).unwrap()).unwrap(); + assert_eq!((pw, ph), (16, 12)); + let u = ex::read_upscale(&text).unwrap(); + use sha2::{Digest, Sha256}; + assert_eq!(Sha256::digest(&samples).as_slice(), u.output, "the picture is the record's output"); + let case = |priors: Vec>| ex::Case { + upscales: vec![text.clone()], + objects: vec![envelope.clone()], + decoded: vec![(wai::jwp_hash::content_hash(&envelope), rgb.clone())], + priors, + policy: ex::Policy { require_proof: true, ..Default::default() }, + ..Default::default() + }; + assert!(matches!(ex::verify(&case(vec![std::fs::read(&prior).unwrap()])), ex::Outcome::Accept(_))); + assert!(!matches!(ex::verify(&case(vec![])), ex::Outcome::Accept(_)), "with no prior, nothing is proved"); + + // `wai upscale verify` holds what it wrote, and refuses what it did not. + let verify = |d: &Path, args: &[&str]| -> (bool, serde_json::Value) { + let o = wai_in(d, &[&["upscale", "verify", "r.json", "--envelope", "in.wai"], args].concat()); + (o.status.success(), serde_json::from_slice(&o.stdout).unwrap_or(serde_json::Value::Null)) + }; + let (good, v) = verify(&d, &["--prior", &prior, "--picture", "out.png"]); + assert!(good, "{v}"); + assert_eq!((&v["outcome"], &v["subpixels"]), (&serde_json::json!("accept"), &serde_json::json!(16 * 12 * 3))); + let mut other = samples.clone(); + other[0] ^= 1; + std::fs::write(d.join("other.png"), wai::png_still::encode(&other, 16, 12)).unwrap(); + let (good, v) = verify(&d, &["--prior", &prior, "--picture", "other.png"]); + assert!(!good && v["reason"] == "picture_mismatch", "{v}"); + let title = Path::new(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/upscale/priors/title.wsr"); + let (good, v) = verify(&d, &["--prior", title.to_str().unwrap()]); + assert!(!good && v["reason"] == "upscale_not_proved", "another prior proves nothing: {v}"); + + // The picture alone needs no key; a record does, and names the envelope. + ok(&wai_in(&d, &["upscale", "run", "in.png", "--prior", &prior, "-o", "bare.png"])); + assert_eq!(std::fs::read(d.join("bare.png")).unwrap(), std::fs::read(d.join("out.png")).unwrap(), "the derivation is exact"); + assert!(!wai_in(&d, &["upscale", "run", "in.png", "--prior", &prior, "-o", "x.png", "--record", "x.json", "--envelope", "x.wai"]).status.success(), "a record needs a key"); + assert!(!wai_in(&d, &["upscale", "run", "in.png", "--prior", &prior, "-o", "x.png", "--record", "x.json", "--key", SEED]).status.success(), "a record needs its envelope"); + assert!(!wai_in(&d, &["upscale", "run", "in.png", "--prior", &prior, "-o", "x.png", "--derivation", "sr3"]).status.success()); + std::fs::write(d.join("grey.png"), wai::png_still::encode_grey(&[9u8; 48], 8, 6)).unwrap(); + assert!(!wai_in(&d, &["upscale", "run", "grey.png", "--prior", &prior, "-o", "x.png"]).status.success(), "the reader holds 8-bit RGB"); +} + +fn hdr_envelope(w: u32, h: u32) -> Vec { + use wai::container::{Rendition, WaiMulti}; + let e = tm::corpus::encode(&tm::corpus::scene(0, w, h), w, h, 4, false, (1, 1), false); + let companion = tm::wgm1(&e.metadata.to_bytes(), &wai::png_still::encode_grey(&e.map, e.map_w, e.map_h)); + WaiMulti::new( + "image", + "replicate", + serde_json::Value::Null, + vec![ + Rendition::media(wai::codecs::CAP_IMAGE_PNG, "png", wai::png_still::encode(&e.base, w, h)).with_id("base").with_payload_digest(), + Rendition::companion(tm::CAP_GAINMAP, companion, wai::codecs::CAP_IMAGE_PNG).with_id("gainmap").with_companion_of_id("base").with_payload_digest(), + ], + ) + .to_bytes() + .unwrap() +} + +#[test] +fn a_tone_mapping_writes_what_exact_tonemap_proves() { + let d = scratch("tonemap"); + let envelope = hdr_envelope(16, 12); + std::fs::write(d.join("in.wai"), &envelope).unwrap(); + let o = wai_in(&d, &["tonemap", "run", "in.wai", "--headrooms", "20000,10000", "--out", "r", "--record", "r.json", "--key", SEED, "--json"]); + let v: serde_json::Value = serde_json::from_slice(&ok(&o).stdout).unwrap(); + assert_eq!(v["data"]["subpixels"], 2 * 16 * 12 * 3); + + let text = std::fs::read_to_string(d.join("r.json")).unwrap(); + let r = tm::read_record(&text).unwrap(); + // Ascending by headroom, each file the bytes its picture states. + assert_eq!(r.pictures.iter().map(|p| p.headroom).collect::>(), vec![10_000, 20_000]); + for p in &r.pictures { + let b = std::fs::read(d.join(format!("r/rendition-h{}.rgb16", p.headroom))).unwrap(); + assert_eq!(tm::digest(&b), p.output, "headroom {}", p.headroom); + } + let mut held = tm::Held::default(); + held.hold_envelope(envelope); + assert!(matches!(tm::verify(std::slice::from_ref(&text), &held, tm::Policy::RequireProof), tm::Outcome::Accept(_))); + assert!(!matches!(tm::verify(std::slice::from_ref(&text), &tm::Held::default(), tm::Policy::RequireProof), tm::Outcome::Accept(_)), "with no envelope, nothing is proved"); + + // `wai tonemap verify` holds what it wrote, and refuses what it did not. + let o = wai_in(&d, &["tonemap", "verify", "r.json", "--envelope", "in.wai", "--renditions", "r"]); + let v: serde_json::Value = serde_json::from_slice(&ok(&o).stdout).unwrap(); + assert_eq!((&v["outcome"], &v["subpixels"]), (&serde_json::json!("accept"), &serde_json::json!(2 * 16 * 12 * 3))); + let path = d.join("r/rendition-h20000.rgb16"); + let mut b = std::fs::read(&path).unwrap(); + b[0] ^= 1; + std::fs::write(&path, b).unwrap(); + let o = wai_in(&d, &["tonemap", "verify", "r.json", "--envelope", "in.wai", "--renditions", "r"]); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert!(!o.status.success() && v["reason"] == "rendition_mismatch" && v["at"] == serde_json::json!(["renditions", 1]), "{v}"); + std::fs::write(d.join("other.wai"), hdr_envelope(8, 6)).unwrap(); + let o = wai_in(&d, &["tonemap", "verify", "r.json", "--envelope", "other.wai"]); + let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert!(!o.status.success() && v["reason"] == "rendition_not_proved", "another envelope proves nothing: {v}"); + + assert!(!wai_in(&d, &["tonemap", "run", "in.wai", "--headrooms", "10,10", "--out", "x"]).status.success(), "headrooms are distinct"); + assert!(!wai_in(&d, &["tonemap", "run", "in.wai", "--headrooms", "60001", "--out", "x"]).status.success(), "past the largest headroom"); + assert!(!wai_in(&d, &["tonemap", "run", "in.wai", "--headrooms", "ten", "--out", "x"]).status.success()); + assert!(!wai_in(&d, &["tonemap", "run", "in.wai", "--headrooms", "10", "--out", "x", "--input-class", "invented"]).status.success()); + std::fs::write(d.join("one.wai"), b"WAI1 not this").unwrap(); + assert!(!wai_in(&d, &["tonemap", "run", "one.wai", "--headrooms", "10", "--out", "x"]).status.success(), "a gain map needs a WAI2"); +} diff --git a/wai/wai-rs/tests/cli_ops.rs b/wai/wai-rs/tests/cli_ops.rs index 0486063e3..b84e2044f 100644 --- a/wai/wai-rs/tests/cli_ops.rs +++ b/wai/wai-rs/tests/cli_ops.rs @@ -490,6 +490,32 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec, bool)> let model = wai_dir().join("wai-rs/tests/vectors/generate/generate_spectral_model.json"); more.push(("media.generate", s(&["generate", "--model", model.to_str().unwrap(), "--prompt", "a calm spectral horizon", "--seed", "7", "--steps", "1", "--size", "16x16", "-o", "g.rgb"]), true)); } + #[cfg(feature = "exact_upscale")] + { + let prior = wai_dir().join("live-conformance/upscale/priors/generic.wsr"); + std::fs::write(d.join("small.png"), wai::png_still::encode(&[9u8; 3 * 8 * 6], 8, 6)).unwrap(); + more.push(("media.upscale", s(&["upscale", "run", "small.png", "--prior", prior.to_str().unwrap(), "-o", "up.png"]), true)); + } + #[cfg(feature = "exact_tonemap")] + { + use wai::exact_tonemap as tm; + let (w, h) = (16, 12); + let e = tm::corpus::encode(&tm::corpus::scene(0, w, h), w, h, 4, false, (1, 1), false); + let companion = tm::wgm1(&e.metadata.to_bytes(), &wai::png_still::encode_grey(&e.map, e.map_w, e.map_h)); + let envelope = wai::container::WaiMulti::new( + "image", + "replicate", + serde_json::Value::Null, + vec![ + wai::container::Rendition::media(wai::codecs::CAP_IMAGE_PNG, "png", wai::png_still::encode(&e.base, w, h)).with_id("base").with_payload_digest(), + wai::container::Rendition::companion(tm::CAP_GAINMAP, companion, wai::codecs::CAP_IMAGE_PNG).with_id("gainmap").with_companion_of_id("base").with_payload_digest(), + ], + ) + .to_bytes() + .unwrap(); + std::fs::write(d.join("hdr.wai"), envelope).unwrap(); + more.push(("media.tonemap", s(&["tonemap", "run", "hdr.wai", "--headrooms", "20000", "--out", "renditions"]), true)); + } #[cfg(feature = "score")] { let notes = wai_dir().join("score-conformance/notes/scale.json"); diff --git a/wai/wai-studio/src/actions.generated.js b/wai/wai-studio/src/actions.generated.js index 6386e778e..8d4d2ca93 100644 --- a/wai/wai-studio/src/actions.generated.js +++ b/wai/wai-studio/src/actions.generated.js @@ -1,7 +1,7 @@ // GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the // catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run // `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs. -export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"7168ac79a55b09ed96bb81d0ada8602e01358164135d0de4f6b2077830ef8497"}; +export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"27cbe1a11c9526c51f021e8b07c9107cdcd94f2a72c80bc8e1f7fb0b2f4b117a"}; export const ACTIONS = [ {"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"}, {"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"}, @@ -13,6 +13,10 @@ export const ACTIONS = [ {"id":"media.encode","noun":"media","verb":"encode","menu":"Media","item":"Encode","palette":"media encode","summary":"Encode a file with a codec this build holds, in an envelope; AVC and HEVC through the device's own encoders, with a signed record","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai encode","mcp":null,"route":null,"call":"media_encode"}, {"id":"media.decode","noun":"media","verb":"decode","menu":"Media","item":"Decode","palette":"media decode","summary":"Decode an envelope through SPEC §4's dispatch to raw samples, with a .meta file beside them","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai decode","mcp":null,"route":null,"call":"media_decode"}, {"id":"media.generate","noun":"media","verb":"generate","menu":"Media","item":"Generate","palette":"media generate","summary":"Make a wai.generate.spectral picture natively and print its SHA-256, or write its instruction object","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai generate","mcp":null,"route":null,"call":"media_generate"}, + {"id":"media.upscale","noun":"media","verb":"upscale","menu":"Media","item":"Upscale","palette":"media upscale","summary":"Upscale a PNG exactly, by an integer network under a pinned prior, with a signed record a verifier recomputes","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai upscale run","mcp":null,"route":null,"call":"media_upscale"}, + {"id":"upscale.verify","noun":"upscale","verb":"verify","menu":"Upscale","item":"Verify","palette":"upscale verify","summary":"An upscale record recomputed from its input and its prior, and the picture held to it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai upscale verify","mcp":null,"route":null,"call":"upscale_verify"}, + {"id":"media.tonemap","noun":"media","verb":"tonemap","menu":"Media","item":"Tonemap","palette":"media tonemap","summary":"Render a still's gain map exactly at the headrooms named, with a signed record a verifier recomputes","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai tonemap run","mcp":null,"route":null,"call":"media_tonemap"}, + {"id":"tonemap.verify","noun":"tonemap","verb":"verify","menu":"Tonemap","item":"Verify","palette":"tonemap verify","summary":"A rendition record recomputed from its envelope, and the renditions held to it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai tonemap verify","mcp":null,"route":null,"call":"tonemap_verify"}, {"id":"score.build","noun":"score","verb":"build","menu":"Score","item":"Build","palette":"score build","summary":"A WSCR score from integer note fields","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai score build","mcp":null,"route":null,"call":"score_build"}, {"id":"score.decode","noun":"score","verb":"decode","menu":"Score","item":"Decode","palette":"score decode","summary":"A score's reference mixdown and its digests (pcm_sha256, mixdown_blake3, score_hash)","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai score render","mcp":null,"route":null,"call":"score_decode"}, {"id":"encoder.list","noun":"encoder","verb":"list","menu":"Encoder","item":"List","palette":"encoder list","summary":"What each encoder interface offers on this device, for AVC and HEVC","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai encoders","mcp":null,"route":null,"call":"encoder_list"},