From 193228ff824b14d37a250b5a760a046a98634728 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:12:00 -0400 Subject: [PATCH 01/24] wai: the canonical JSON form in its own module, ungated RFC 8785 over WAI's signing subset moves from claim_receipt to src/jcs.rs, compiled under every feature set (it needs serde_json alone), so a packaging policy's digest canonicalises through the same function claims, key logs and energy figures use. Behaviour is unchanged: null admitted, integers up to 2^53 - 1, everything else refused. claim_receipt::jcs keeps its signature and maps the refusal to malformed, as before. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/claim_receipt.rs | 55 ++------------- wai/wai-rs/src/jcs.rs | 116 ++++++++++++++++++++++++++++++++ wai/wai-rs/src/lib.rs | 5 ++ 3 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 wai/wai-rs/src/jcs.rs diff --git a/wai/wai-rs/src/claim_receipt.rs b/wai/wai-rs/src/claim_receipt.rs index 54ad62508..52fe29148 100644 --- a/wai/wai-rs/src/claim_receipt.rs +++ b/wai/wai-rs/src/claim_receipt.rs @@ -118,9 +118,8 @@ pub const DOMAIN_CLAIM_LINK: &[u8] = b"moq-jwp:claim-link\x01"; pub use crate::jwp_hash::{content_hash as object_content_hash, DOMAIN_OBJECT}; /// The largest integer a claim may carry: I-JSON's (RFC 7493) exact-integer bound, -/// which is what keeps RFC 8785 number serialisation trivial. Crate-private so the -/// C header (cbindgen exports public integer constants) does not gain a generic name. -pub(crate) const MAX_INTEGER: u64 = (1 << 53) - 1; +/// the canonical form's (`crate::jcs::MAX_INTEGER`). +pub(crate) const MAX_INTEGER: u64 = crate::jcs::MAX_INTEGER; // ───────────────────────────── reasons ───────────────────────────── @@ -2276,52 +2275,12 @@ pub fn recover( // ───────────────────────────── canonical JSON ───────────────────────────── -/// RFC 8785 over the claim subset: member names sorted by UTF-16 code units, strings -/// escaped as RFC 8785 §3.2.2.2 requires (which is serde_json's escaping), integers in -/// `0..=MAX_INTEGER` in plain decimal. Anything else is outside the subset. +/// RFC 8785 over the claim subset (`crate::jcs`): member names sorted by UTF-16 code +/// units, strings escaped as RFC 8785 §3.2.2.2 requires (which is serde_json's +/// escaping), integers in `0..=MAX_INTEGER` in plain decimal. Anything else is outside +/// the subset, and a claim's reason for it is `malformed`. pub fn jcs(v: &Value) -> Result { - let mut out = String::new(); - jcs_into(v, &mut out)?; - Ok(out) -} - -fn jcs_into(v: &Value, out: &mut String) -> Result<(), Reason> { - match v { - Value::Null => out.push_str("null"), - Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), - Value::Number(n) => match n.as_u64() { - Some(u) if u <= MAX_INTEGER => { - let _ = write!(out, "{u}"); - } - _ => return Err(Reason::Malformed), - }, - Value::String(s) => out.push_str(&serde_json::to_string(s).map_err(|_| Reason::Malformed)?), - Value::Array(a) => { - out.push('['); - for (i, x) in a.iter().enumerate() { - if i > 0 { - out.push(','); - } - jcs_into(x, out)?; - } - out.push(']'); - } - Value::Object(m) => { - let mut keys: Vec<&String> = m.keys().collect(); - keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); - out.push('{'); - for (i, k) in keys.iter().enumerate() { - if i > 0 { - out.push(','); - } - out.push_str(&serde_json::to_string(k).map_err(|_| Reason::Malformed)?); - out.push(':'); - jcs_into(&m[k.as_str()], out)?; - } - out.push('}'); - } - } - Ok(()) + crate::jcs::jcs(v).map_err(|_| Reason::Malformed) } /// §3.1 step 1's reading of a JSON text: RFC 8259 with a claim's limits — no repeated diff --git a/wai/wai-rs/src/jcs.rs b/wai/wai-rs/src/jcs.rs new file mode 100644 index 000000000..eb3d8b0fc --- /dev/null +++ b/wai/wai-rs/src/jcs.rs @@ -0,0 +1,116 @@ +//! RFC 8785 (JSON Canonicalization Scheme) over the subset WAI signs and digests. +//! +//! One canonical form for every JSON object WAI hashes or signs: a claim's body and +//! signing input (`claim_receipt`), a key log's revisions, grants and statuses +//! (`key_log`), an energy figure's claim body (`energy_figure`), and a packaging +//! policy's digest (`extensions/packaging.md` §3). It needs `serde_json` alone, so it is +//! compiled under every feature set. +//! +//! The subset: `null`, `true`, `false`, strings, arrays, objects, and integers in +//! `0..=MAX_INTEGER` written in plain decimal. Member names are sorted by their UTF-16 +//! code units and strings are escaped as RFC 8785 §3.2.2.2 requires, which is how +//! `serde_json` escapes them. Any other number (a negative, a fraction, an exponent +//! form, an integer past `2^53 − 1`) is outside the subset and refused, so a value is +//! never canonicalised in a form two readers could read differently. + +use serde_json::Value; +use std::fmt::Write as _; + +/// The largest integer the subset admits: I-JSON's (RFC 7493) exact-integer bound, +/// which is what keeps RFC 8785 number serialisation trivial. +/// +/// cbindgen:ignore +pub const MAX_INTEGER: u64 = (1 << 53) - 1; + +/// Why a value has no canonical form in the subset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JcsError { + /// A number that is not an integer in `0..=MAX_INTEGER`. + Number, + /// A string or member name the serialiser refused (a valid `String` never is). + String, +} + +impl std::fmt::Display for JcsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + JcsError::Number => "a number outside the integers 0..=2^53-1", + JcsError::String => "a string the serialiser refused", + }) + } +} + +impl std::error::Error for JcsError {} + +/// The canonical form of `v`: RFC 8785 over the subset above. +pub fn jcs(v: &Value) -> Result { + let mut out = String::new(); + jcs_into(v, &mut out)?; + Ok(out) +} + +fn jcs_into(v: &Value, out: &mut String) -> Result<(), JcsError> { + match v { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => match n.as_u64() { + Some(u) if u <= MAX_INTEGER => { + let _ = write!(out, "{u}"); + } + _ => return Err(JcsError::Number), + }, + Value::String(s) => out.push_str(&serde_json::to_string(s).map_err(|_| JcsError::String)?), + Value::Array(a) => { + out.push('['); + for (i, x) in a.iter().enumerate() { + if i > 0 { + out.push(','); + } + jcs_into(x, out)?; + } + out.push(']'); + } + Value::Object(m) => { + let mut keys: Vec<&String> = m.keys().collect(); + keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + out.push('{'); + for (i, k) in keys.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(k).map_err(|_| JcsError::String)?); + out.push(':'); + jcs_into(&m[k.as_str()], out)?; + } + out.push('}'); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Member order by UTF-16 code units (so a supplementary-plane character sorts + /// before U+FF61, which it follows in code-point order), RFC 8785's escapes, and + /// `null` admitted. + #[test] + fn sorts_by_utf16_and_escapes_like_rfc8785() { + let v = json!({"b": 1, "a": "x\u{1}\n\"", "A": [2, "é"], "é": null}); + assert_eq!(jcs(&v).unwrap(), "{\"A\":[2,\"é\"],\"a\":\"x\\u0001\\n\\\"\",\"b\":1,\"é\":null}"); + let v = json!({"\u{ff61}": 1, "\u{1f600}": 2}); + assert_eq!(jcs(&v).unwrap(), "{\"\u{1f600}\":2,\"\u{ff61}\":1}"); + } + + /// The subset's numbers: integers up to `2^53 − 1`, and nothing else. + #[test] + fn refuses_numbers_outside_the_subset() { + assert_eq!(jcs(&json!({"n": -1})), Err(JcsError::Number)); + assert_eq!(jcs(&json!({"n": 1.5})), Err(JcsError::Number)); + assert_eq!(jcs(&json!([MAX_INTEGER + 1])), Err(JcsError::Number)); + assert_eq!(jcs(&json!({"n": MAX_INTEGER})).unwrap(), "{\"n\":9007199254740991}"); + assert_eq!(jcs(&json!(0)).unwrap(), "0"); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 00d5c98e9..de59bc29b 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -294,6 +294,11 @@ pub mod energy_binding; #[cfg(feature = "c2pa_emit")] pub mod c2pa_emit; +// RFC 8785 canonical JSON over the subset WAI signs and digests (integers up to +// 2^53 − 1, no floats). Needs serde_json alone, so it is ungated: claims, key logs, +// energy figures and packaging policies canonicalise through this one function. +pub mod jcs; + // jwp-receipts §2.1: an object's content hash, BLAKE3 over a domain and the envelope. // One module, gated only on BLAKE3, so every receipt family computes it in one place. #[cfg(feature = "jwp_hash")] -- 2.54.0 (Apple Git-157) From fb61bb69bd4f07cb82772519a3977331bdd548fe Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:33:52 -0400 Subject: [PATCH 02/24] wai-pack: the policy, scene-cut segmentation and their corpus A new crate beside wai-rs, with its own workspace and lock: the reference packager of the packaging extension, built so far as far as a plan. - policy.rs: wai.pack.policy/1 read in three stages (value kinds a canonical JSON cannot carry; the schema with every default written in; the constraints across members), each refusal named. The effective policy's canonical JSON goes through wai::jcs, and its digest is BLAKE3("wai:pack-policy\x01" || JCS). - source/: Y4M to RGB8 (BT.709 limited range, integer), WAV PCM s16le. - scenecut.rs: wai.pack.scenecut/1, one streaming segmenter with a bounded lookahead and the return and revisit clauses, so live and on-demand packing cut alike at equal lookahead; plan digest. - classify.rs: static, synthetic, high motion, natural. - fixture.rs: integer-generated Y4M fixtures (cuts-01, kinds-01). - conformance.rs and wai-pack conformance regen|check. pack-conformance/ holds the policy digests and equal-spelling pairs, a refusal case per reason, the unit and colour conversions, and the scene-cut vectors; pack_verify.py recomputes every one of them from the rules, with its own TOML schema, JCS, readers, fixture generator and segmenter. It found one defect while it was written: a millisecond bound converted to an extreme timebase wrapped past 2^64; both conversions now saturate. Co-Authored-By: Claude Opus 5.5 --- wai/pack-conformance/conversions.json | 149 ++ .../conversions/bt709-420.y4m | Bin 0 -> 229 bytes .../conversions/bt709-444.y4m | Bin 0 -> 183 bytes wai/pack-conformance/pack_verify.py | 909 +++++++++++++ .../policy/eq-defaults-a.toml | 15 + .../policy/eq-defaults-b.toml | 43 + .../policy/eq-spelling-a.toml | 40 + .../policy/eq-spelling-b.toml | 32 + wai/pack-conformance/policy/example.toml | 145 ++ wai/pack-conformance/policy/expected.json | 47 + wai/pack-conformance/refusals/expected.json | 168 +++ .../refusals/policy/class-layer.toml | 19 + .../refusals/policy/dangling-refines.toml | 23 + .../refusals/policy/datetime.toml | 20 + .../refusals/policy/deadline-unreachable.toml | 29 + .../refusals/policy/duplicate-id.toml | 21 + .../refusals/policy/float.toml | 18 + .../refusals/policy/integer-range.toml | 18 + .../refusals/policy/ladder-component.toml | 21 + .../refusals/policy/layer-lag.toml | 26 + .../refusals/policy/lead.toml | 15 + .../refusals/policy/missing-key.toml | 14 + .../refusals/policy/negative.toml | 18 + .../refusals/policy/no-base.toml | 26 + .../refusals/policy/open-gop.toml | 18 + .../refusals/policy/piece-bounds.toml | 18 + .../refusals/policy/refines-base.toml | 29 + .../refusals/policy/refines-branch.toml | 31 + .../refusals/policy/refines-cycle.toml | 31 + .../refusals/policy/refines-role.toml | 22 + .../refusals/policy/rule-ladder.toml | 25 + .../refusals/policy/schema.toml | 15 + .../refusals/policy/toml-duplicate-key.toml | 16 + .../refusals/policy/toml.toml | 15 + .../refusals/policy/too-many-components.toml | 55 + .../refusals/policy/too-many-deadlines.toml | 39 + .../refusals/policy/too-many-moments.toml | 180 +++ .../refusals/policy/too-many-rules.toml | 114 ++ .../refusals/policy/type.toml | 16 + .../refusals/policy/unknown-key-params.toml | 16 + .../refusals/policy/unknown-key.toml | 18 + .../refusals/policy/value-block.toml | 16 + .../refusals/policy/value-media.toml | 15 + .../refusals/source/wav-format-8bit.wav | Bin 0 -> 48 bytes .../refusals/source/wav-format-float.wav | Bin 0 -> 52 bytes .../refusals/source/wav-truncated.wav | Bin 0 -> 50 bytes .../refusals/source/y4m-colorspace.y4m | 3 + .../refusals/source/y4m-header.y4m | 1 + .../refusals/source/y4m-interlaced-frame.y4m | 3 + .../refusals/source/y4m-interlaced.y4m | 3 + .../refusals/source/y4m-size-change.y4m | 4 + .../refusals/source/y4m-truncated.y4m | 3 + wai/pack-conformance/scenecut/expected.json | 783 +++++++++++ wai/pack-conformance/scenecut/policy.toml | 23 + wai/wai-pack/Cargo.lock | 528 ++++++++ wai/wai-pack/Cargo.toml | 34 + wai/wai-pack/deny.toml | 33 + wai/wai-pack/src/bin/wai_pack.rs | 147 ++ wai/wai-pack/src/classify.rs | 79 ++ wai/wai-pack/src/conformance.rs | 328 +++++ wai/wai-pack/src/fixture.rs | 161 +++ wai/wai-pack/src/lib.rs | 34 + wai/wai-pack/src/policy.rs | 1195 +++++++++++++++++ wai/wai-pack/src/scenecut.rs | 389 ++++++ wai/wai-pack/src/source/mod.rs | 88 ++ wai/wai-pack/src/source/wav.rs | 152 +++ wai/wai-pack/src/source/y4m.rs | 232 ++++ wai/wai-pack/tests/conformance.rs | 12 + wai/wai-pack/tests/scenecut.rs | 60 + 69 files changed, 6800 insertions(+) create mode 100644 wai/pack-conformance/conversions.json create mode 100644 wai/pack-conformance/conversions/bt709-420.y4m create mode 100644 wai/pack-conformance/conversions/bt709-444.y4m create mode 100644 wai/pack-conformance/pack_verify.py create mode 100644 wai/pack-conformance/policy/eq-defaults-a.toml create mode 100644 wai/pack-conformance/policy/eq-defaults-b.toml create mode 100644 wai/pack-conformance/policy/eq-spelling-a.toml create mode 100644 wai/pack-conformance/policy/eq-spelling-b.toml create mode 100644 wai/pack-conformance/policy/example.toml create mode 100644 wai/pack-conformance/policy/expected.json create mode 100644 wai/pack-conformance/refusals/expected.json create mode 100644 wai/pack-conformance/refusals/policy/class-layer.toml create mode 100644 wai/pack-conformance/refusals/policy/dangling-refines.toml create mode 100644 wai/pack-conformance/refusals/policy/datetime.toml create mode 100644 wai/pack-conformance/refusals/policy/deadline-unreachable.toml create mode 100644 wai/pack-conformance/refusals/policy/duplicate-id.toml create mode 100644 wai/pack-conformance/refusals/policy/float.toml create mode 100644 wai/pack-conformance/refusals/policy/integer-range.toml create mode 100644 wai/pack-conformance/refusals/policy/ladder-component.toml create mode 100644 wai/pack-conformance/refusals/policy/layer-lag.toml create mode 100644 wai/pack-conformance/refusals/policy/lead.toml create mode 100644 wai/pack-conformance/refusals/policy/missing-key.toml create mode 100644 wai/pack-conformance/refusals/policy/negative.toml create mode 100644 wai/pack-conformance/refusals/policy/no-base.toml create mode 100644 wai/pack-conformance/refusals/policy/open-gop.toml create mode 100644 wai/pack-conformance/refusals/policy/piece-bounds.toml create mode 100644 wai/pack-conformance/refusals/policy/refines-base.toml create mode 100644 wai/pack-conformance/refusals/policy/refines-branch.toml create mode 100644 wai/pack-conformance/refusals/policy/refines-cycle.toml create mode 100644 wai/pack-conformance/refusals/policy/refines-role.toml create mode 100644 wai/pack-conformance/refusals/policy/rule-ladder.toml create mode 100644 wai/pack-conformance/refusals/policy/schema.toml create mode 100644 wai/pack-conformance/refusals/policy/toml-duplicate-key.toml create mode 100644 wai/pack-conformance/refusals/policy/toml.toml create mode 100644 wai/pack-conformance/refusals/policy/too-many-components.toml create mode 100644 wai/pack-conformance/refusals/policy/too-many-deadlines.toml create mode 100644 wai/pack-conformance/refusals/policy/too-many-moments.toml create mode 100644 wai/pack-conformance/refusals/policy/too-many-rules.toml create mode 100644 wai/pack-conformance/refusals/policy/type.toml create mode 100644 wai/pack-conformance/refusals/policy/unknown-key-params.toml create mode 100644 wai/pack-conformance/refusals/policy/unknown-key.toml create mode 100644 wai/pack-conformance/refusals/policy/value-block.toml create mode 100644 wai/pack-conformance/refusals/policy/value-media.toml create mode 100644 wai/pack-conformance/refusals/source/wav-format-8bit.wav create mode 100644 wai/pack-conformance/refusals/source/wav-format-float.wav create mode 100644 wai/pack-conformance/refusals/source/wav-truncated.wav create mode 100644 wai/pack-conformance/refusals/source/y4m-colorspace.y4m create mode 100644 wai/pack-conformance/refusals/source/y4m-header.y4m create mode 100644 wai/pack-conformance/refusals/source/y4m-interlaced-frame.y4m create mode 100644 wai/pack-conformance/refusals/source/y4m-interlaced.y4m create mode 100644 wai/pack-conformance/refusals/source/y4m-size-change.y4m create mode 100644 wai/pack-conformance/refusals/source/y4m-truncated.y4m create mode 100644 wai/pack-conformance/scenecut/expected.json create mode 100644 wai/pack-conformance/scenecut/policy.toml create mode 100644 wai/wai-pack/Cargo.lock create mode 100644 wai/wai-pack/Cargo.toml create mode 100644 wai/wai-pack/deny.toml create mode 100644 wai/wai-pack/src/bin/wai_pack.rs create mode 100644 wai/wai-pack/src/classify.rs create mode 100644 wai/wai-pack/src/conformance.rs create mode 100644 wai/wai-pack/src/fixture.rs create mode 100644 wai/wai-pack/src/lib.rs create mode 100644 wai/wai-pack/src/policy.rs create mode 100644 wai/wai-pack/src/scenecut.rs create mode 100644 wai/wai-pack/src/source/mod.rs create mode 100644 wai/wai-pack/src/source/wav.rs create mode 100644 wai/wai-pack/src/source/y4m.rs create mode 100644 wai/wai-pack/tests/conformance.rs create mode 100644 wai/wai-pack/tests/scenecut.rs diff --git a/wai/pack-conformance/conversions.json b/wai/pack-conformance/conversions.json new file mode 100644 index 000000000..0bc797fdd --- /dev/null +++ b/wai/pack-conformance/conversions.json @@ -0,0 +1,149 @@ +{ + "ms_to_units": [ + { + "ms": 1000, + "num": 25, + "den": 1, + "units": 25 + }, + { + "ms": 40, + "num": 25, + "den": 1, + "units": 1 + }, + { + "ms": 39, + "num": 25, + "den": 1, + "units": 1 + }, + { + "ms": 0, + "num": 25, + "den": 1, + "units": 1 + }, + { + "ms": 1000, + "num": 30000, + "den": 1001, + "units": 29 + }, + { + "ms": 2000, + "num": 30000, + "den": 1001, + "units": 59 + }, + { + "ms": 1, + "num": 48000, + "den": 1, + "units": 48 + }, + { + "ms": 4000, + "num": 90000, + "den": 1, + "units": 360000 + }, + { + "ms": 9007199254740991, + "num": 4294967295, + "den": 1, + "units": 18446744073709551615 + } + ], + "align": [ + { + "start": 50, + "lead": [ + 25, + 1 + ], + "component": [ + 48000, + 1 + ], + "units": 96000 + }, + { + "start": 3, + "lead": [ + 25, + 1 + ], + "component": [ + 30000, + 1001 + ], + "units": 3 + }, + { + "start": 29, + "lead": [ + 30000, + 1001 + ], + "component": [ + 25, + 1 + ], + "units": 24 + }, + { + "start": 48000, + "lead": [ + 48000, + 1 + ], + "component": [ + 25, + 1 + ], + "units": 25 + }, + { + "start": 47999, + "lead": [ + 48000, + 1 + ], + "component": [ + 25, + 1 + ], + "units": 24 + }, + { + "start": 18446744073709551615, + "lead": [ + 1, + 4294967295 + ], + "component": [ + 4294967295, + 1 + ], + "units": 18446744073709551615 + } + ], + "y4m_rgb": [ + { + "file": "conversions/bt709-420.y4m", + "rgb_sha256": [ + "c1d16f98e2ae61ba8cca146cb1856b462e5c7641590ad651fe727658377253a8", + "badec76a4621b64df3cf803df18c58e08d70ced9cf716dc6b7b7fb7fd278e74d", + "6d5c08c958a6cbe09257dddfa9976f9c0bdd9943995ed886b1334d4389586629" + ] + }, + { + "file": "conversions/bt709-444.y4m", + "rgb_sha256": [ + "d2d8ef122db155d44015cf30ff760bc2744cecedba19c6f0388f09dbb207acfd", + "ede0a218313910474b8bfd8e7d1fd5743328b800822516c760214e6fb55a711b" + ] + } + ] +} diff --git a/wai/pack-conformance/conversions/bt709-420.y4m b/wai/pack-conformance/conversions/bt709-420.y4m new file mode 100644 index 0000000000000000000000000000000000000000..901fddd6d50d812a6537648628c8d2de84514ed0 GIT binary patch literal 229 zcmVURaP`jP(?>FAXhgaNHrivGBr9eAW3i_Lo_llYH(#|3Pw^vO+^X-B}#9U zx!waPO>&pK;RY*Eb(+8A2`*E3oxr@WTb5pqd>H;Uab^66ww literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/conversions/bt709-444.y4m b/wai/pack-conformance/conversions/bt709-444.y4m new file mode 100644 index 0000000000000000000000000000000000000000..7472bc9291ded99931bc02344f85d7f1e5bce63b GIT binary patch literal 183 zcmV;o07(B?RaP`jP(?>FAXhdZNHicuGBr9eAVV}XGzvyiK}|&p03}Lql)2smC{1#g zyx|5bP<5KWB&`802;@DD{ZOO!Nu2LO zmFPQ;;WUTZMp8jdMG9q#uhjV-Luie%*Zd(yYml|t|0GFnleiR5ip2mscCqXyVx8C% lPKm_-I(4w?CSiirD0|K!bjKQPz!PS<3}3PZSgZa~r1#w!P&fbp literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py new file mode 100644 index 000000000..065890754 --- /dev/null +++ b/wai/pack-conformance/pack_verify.py @@ -0,0 +1,909 @@ +#!/usr/bin/env python3 +"""Independent verifier of the packaging corpus (extensions/packaging.md). + +Written from the extension text. It shares no code with wai-pack, which generates the +corpus. It takes BLAKE3 from ../claim-conformance/_blake3.py, written from its +specification, reads TOML with the standard library's tomllib (TOML 1.0), and implements +the rest: + +- the policy (§3): the value-kind walk, the schema with every default, the constraints + and their refusal names, the effective policy as JSON, RFC 8785 canonical JSON and the + digest; every case of policy/ is recomputed, every equal pair compared, and every + policy of refusals/policy/ refused with its reason; +- the sources (§4.7): a Y4M reader with BT.709 limited-range conversion to RGB8, and a + WAV reader; every source of refusals/source/ refused with its reason, and every frame + of conversions/ converted to the SHA-256 the corpus states; +- the unit conversions (§4.4, §4.5); +- the fixtures, regenerated from their definition and checked against their pinned + SHA-256, and wai.pack.scenecut/1 (§4.3) with its lookahead clauses, written in the + on-demand form (every frame known), against the streaming segmenter's vectors; and the + classification (§4.6) of every piece. + + python3 pack_verify.py + +Exit status 0 only if every vector verifies. Python 3.11 or later (tomllib). +""" +import hashlib +import json +import os +import sys +import tomllib + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) +from _blake3 import blake3 # noqa: E402 + +MAX_INTEGER = (1 << 53) - 1 +U32 = (1 << 32) - 1 + +# ---------------------------------------------------------------- canonical JSON (RFC 8785) + + +def jcs(v): + if v is None: + return "null" + if v is True: + return "true" + if v is False: + return "false" + if isinstance(v, int): + if not 0 <= v <= MAX_INTEGER: + raise ValueError("an integer outside the canonical subset") + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "[" + ",".join(jcs(x) for x in v) + "]" + if isinstance(v, dict): + keys = sorted(v, key=lambda k: k.encode("utf-16-be")) + return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + jcs(v[k]) for k in keys) + "}" + raise ValueError("a value outside the canonical subset") + + +# ---------------------------------------------------------------- policy (§3) + + +class Refused(Exception): + def __init__(self, code, at=""): + super().__init__(code) + self.code = code + self.at = at + + +def is_int(v): + return isinstance(v, int) and not isinstance(v, bool) + + +def kinds(v, at): + """Stage 1: the value kinds canonical JSON cannot carry, members in key order.""" + import datetime + + if isinstance(v, float): + raise Refused("policy-float", at) + if isinstance(v, (datetime.datetime, datetime.date, datetime.time)): + raise Refused("policy-datetime", at) + if is_int(v) and v < 0: + raise Refused("policy-negative", at) + if is_int(v) and v > MAX_INTEGER: + raise Refused("policy-integer-range", at) + if isinstance(v, list): + for i, x in enumerate(v): + kinds(x, f"{at}[{i}]") + if isinstance(v, dict): + for k in sorted(v): + kinds(v[k], f"{at}.{k}" if at else k) + + +class Tab: + def __init__(self, t, at): + self.t, self.at = t, at + + def err(self, code, k): + return Refused(code, f"{self.at}.{k}" if self.at else k) + + def keys(self, allowed): + for k in sorted(self.t): + if k not in allowed: + raise self.err("policy-unknown-key", k) + + def string(self, k): + if k not in self.t: + return None + if not isinstance(self.t[k], str): + raise self.err("policy-type", k) + return self.t[k] + + def string_req(self, k): + s = self.string(k) + if s is None: + raise self.err("policy-missing-key", k) + return s + + def ident(self, k): + s = self.string_req(k) + if not s or len(s.encode()) > 255: + raise self.err("policy-value", k) + return s + + def uint(self, k): + if k not in self.t: + return None + if not is_int(self.t[k]): + raise self.err("policy-type", k) + return self.t[k] + + def uint_in(self, k, lo, hi, default): + v = self.uint(k) + if v is None: + if default is None: + raise self.err("policy-missing-key", k) + return default + if v < lo or v > hi: + raise self.err("policy-value", k) + return v + + def uint_opt_in(self, k, lo, hi): + v = self.uint(k) + if v is not None and (v < lo or v > hi): + raise self.err("policy-value", k) + return v + + def boolean(self, k, default): + if k not in self.t: + return default + if not isinstance(self.t[k], bool): + raise self.err("policy-type", k) + return self.t[k] + + def table(self, k): + if k not in self.t: + return Tab({}, f"{self.at}.{k}" if self.at else k) + if not isinstance(self.t[k], dict): + raise self.err("policy-type", k) + return Tab(self.t[k], f"{self.at}.{k}" if self.at else k) + + def table_req(self, k): + if k not in self.t: + raise self.err("policy-missing-key", k) + return self.table(k) + + def tables(self, k): + if k not in self.t: + return [] + v = self.t[k] + if not isinstance(v, list) or not all(isinstance(x, dict) for x in v): + raise self.err("policy-type", k) + return [Tab(x, f"{k}[{i}]") for i, x in enumerate(v)] + + def strings(self, k): + if k not in self.t: + return None + v = self.t[k] + if not isinstance(v, list) or not all(isinstance(x, str) for x in v): + raise self.err("policy-type", k) + return list(v) + + def uints(self, k): + if k not in self.t: + return None + v = self.t[k] + if not isinstance(v, list) or not all(is_int(x) for x in v): + raise self.err("policy-type", k) + return list(v) + + +# §3.3: the parameters each capability's rung takes; another capability takes none. +PARAMS = { + "wai.video.int_motion": [ + ("block", ("one_of", [4, 8, 16, 32], 16)), + ("search", ("int", 0, 32, 7)), + ("mv_precision", ("one_of", [1, 2], 2)), + ("q_candidates", ("ints", 1, 255, [8])), + ("max_sse_px_milli", ("int", 0, MAX_INTEGER, 0)), + ], + "wai.video.int_refine": [("q", ("int", 1, 255, None))], + "wai.video.av1": [("source", ("word", ["supplied", "encode"], "supplied"))], + "wai.video.av1.lossless": [("source", ("word", ["supplied", "encode"], "supplied"))], + "wai.audio.int_layered": [("rate", ("int", 1, 384000, None))], + "wai.audio.int_refine": [("to_rate", ("int", 1, 384000, None)), ("q", ("int", 1, 65535, None))], + "wai.audio.flac": [("source", ("word", ["encode", "supplied"], "encode"))], +} + +MEDIA = ["video", "image", "audio", "world", "feed", "avatar", "film"] +KINDS = ["natural", "static", "synthetic", "motion_high"] + + +def read_params(rung, cap): + p = rung.table("params") + schema = PARAMS.get(cap, []) + p.keys([k for k, _ in schema]) + out = {} + for k, kind in schema: + if kind[0] == "int": + out[k] = p.uint_in(k, kind[1], kind[2], kind[3]) + elif kind[0] == "one_of": + v = p.uint_in(k, 0, MAX_INTEGER, kind[2]) + if v not in kind[1]: + raise p.err("policy-value", k) + out[k] = v + elif kind[0] == "ints": + v = p.uints(k) + v = list(kind[3]) if v is None else v + if not v or any(x < kind[1] or x > kind[2] for x in v): + raise p.err("policy-value", k) + out[k] = v + else: + v = p.string(k) + v = kind[2] if v is None else v + if v not in kind[1]: + raise p.err("policy-value", k) + out[k] = v + return out + + +def is_hex64(s): + return len(s) == 64 and all(c in "0123456789abcdef" for c in s) + + +def read_policy(doc): + """Stage 2. Returns the policy as a dict of its JSON members (layer_lag_ms unchecked).""" + top = Tab(doc, "") + top.keys(["schema", "asset_id", "royalty_free_only", "lead", "component", "segmentation", "ladder", "floor", + "live", "class", "moment", "rule", "defaults", "classify"]) + if top.string_req("schema") != "wai.pack.policy/1": + raise top.err("policy-schema", "schema") + P = {"schema": "wai.pack.policy/1", "asset_id": top.ident("asset_id"), + "royalty_free_only": top.boolean("royalty_free_only", False), "lead": top.ident("lead")} + if "component" not in doc: + raise top.err("policy-missing-key", "component") + comps = [] + for c in top.tables("component"): + c.keys(["id", "media", "source", "timebase"]) + m = {"id": c.ident("id")} + media = c.string_req("media") + if media not in MEDIA: + raise c.err("policy-value", "media") + m["media"] = media + s = c.string("source") + if s is not None: + m["source"] = s + tb = c.table_req("timebase") + tb.keys(["num", "den"]) + m["timebase"] = {"num": tb.uint_in("num", 1, U32, None), "den": tb.uint_in("den", 1, U32, None)} + comps.append(m) + P["component"] = comps + s = top.table("segmentation") + s.keys(["target_ms", "min_ms", "max_ms", "scene_cut", "cut_abs_permille", "cut_rel_permille", "cut_window", + "lookahead_frames", "closed"]) + seg = {"target_ms": s.uint_in("target_ms", 0, MAX_INTEGER, 2000), + "min_ms": s.uint_in("min_ms", 0, MAX_INTEGER, 1000), + "max_ms": s.uint_in("max_ms", 0, MAX_INTEGER, 4000)} + sc = s.string("scene_cut") + sc = "wai.pack.scenecut/1" if sc is None else sc + if sc != "wai.pack.scenecut/1": + raise s.err("policy-value", "scene_cut") + seg["scene_cut"] = sc + seg["cut_abs_permille"] = s.uint_in("cut_abs_permille", 1, 1000, 300) + seg["cut_rel_permille"] = s.uint_in("cut_rel_permille", 0, 1000000, 3000) + seg["cut_window"] = s.uint_in("cut_window", 1, 64, 8) + seg["lookahead_frames"] = s.uint_in("lookahead_frames", 0, 64, 6) + seg["closed"] = s.boolean("closed", True) + P["segmentation"] = seg + ladder = [] + for r in top.tables("ladder"): + r.keys(["id", "component", "capability", "role", "refines", "params"]) + m = {"id": r.ident("id"), "component": r.ident("component"), "capability": r.string_req("capability")} + role = r.string_req("role") + if role not in ("base", "refinement", "floor"): + raise r.err("policy-value", "role") + m["role"] = role + t = r.string("refines") + if t is not None: + m["refines"] = t + m["params"] = read_params(r, m["capability"]) + ladder.append(m) + P["ladder"] = ladder + f = top.table("floor") + f.keys(["supplied_listing", "extra_keyframes"]) + fl = {} + sl = f.string("supplied_listing") + if sl is not None: + if not is_hex64(sl): + raise f.err("policy-value", "supplied_listing") + fl["supplied_listing"] = sl + fl["extra_keyframes"] = f.boolean("extra_keyframes", False) + P["floor"] = fl + lv = top.table("live") + lv.keys(["layer_lag_ms"]) + lag = lv.uints("layer_lag_ms") + classes = [] + for c in top.tables("class"): + c.keys(["id", "max_layer", "max_kbps", "floor_only"]) + m = {"id": c.ident("id"), "max_layer": c.uint_in("max_layer", 0, 255, 0)} + k = c.uint_opt_in("max_kbps", 1, U32) + if k is not None: + m["max_kbps"] = k + m["floor_only"] = c.boolean("floor_only", False) + classes.append(m) + P["class"] = classes + moments = [] + for m in top.tables("moment"): + m.keys(["id", "from_ms", "to_ms", "tags"]) + x = {"id": m.ident("id"), "from_ms": m.uint_in("from_ms", 0, MAX_INTEGER, None), + "to_ms": m.uint_in("to_ms", 0, MAX_INTEGER, None)} + if x["to_ms"] <= x["from_ms"]: + raise m.err("policy-value", "to_ms") + x["tags"] = m.strings("tags") or [] + moments.append(x) + P["moment"] = moments + rules = [] + for r in top.tables("rule"): + r.keys(["id", "when", "then"]) + x = {"id": r.ident("id")} + w = r.table("when") + w.keys(["piece_kind", "moment_tag"]) + when = {} + pk = w.strings("piece_kind") + if pk is not None: + if any(k not in KINDS for k in pk): + raise w.err("policy-value", "piece_kind") + when["piece_kind"] = pk + mt = w.strings("moment_tag") + if mt is not None: + when["moment_tag"] = mt + t = r.table("then") + t.keys(["ladder", "max_layer", "deadline_ms"]) + then = {} + ld = t.strings("ladder") + if ld is not None: + then["ladder"] = ld + ml = t.uint_opt_in("max_layer", 0, 255) + if ml is not None: + then["max_layer"] = ml + dl = t.uint_opt_in("deadline_ms", 1, U32) + if dl is not None: + then["deadline_ms"] = dl + x["when"], x["then"] = when, then + rules.append(x) + P["rule"] = rules + d = top.table("defaults") + d.keys(["deadline_ms"]) + P["defaults"] = {"deadline_ms": d.uint_in("deadline_ms", 1, U32, 1500)} + c = top.table("classify") + c.keys(["static_permille", "static_mad_milli", "synthetic_bins", "motion_high_milli"]) + P["classify"] = {"static_permille": c.uint_in("static_permille", 0, 1000, 5), + "static_mad_milli": c.uint_in("static_mad_milli", 0, MAX_INTEGER, 300), + "synthetic_bins": c.uint_in("synthetic_bins", 0, 64, 12), + "motion_high_milli": c.uint_in("motion_high_milli", 0, MAX_INTEGER, 9000)} + return P, lag + + +def first_dup(ids): + seen = set() + for i in ids: + if i in seen: + return i + seen.add(i) + return None + + +def layers(P): + by = {r["id"]: r for r in P["ladder"]} + out = {} + for r in P["ladder"]: + k, cur = 0, r + while "refines" in cur and cur["refines"] in by: + k, cur = k + 1, by[cur["refines"]] + out[r["id"]] = k + return out + + +def constrain(P, lag): + """Stage 3, in the order of the extension's list.""" + def no(code): + raise Refused(code) + if not P["component"] or len(P["component"]) > 8: + no("policy-too-many-components") + if len(P["ladder"]) > 64: + no("policy-too-many-rungs") + if len(P["class"]) > 32: + no("policy-too-many-classes") + if len(P["rule"]) > 32: + no("policy-too-many-rules") + if len(P["moment"]) > 32: + no("policy-too-many-moments") + for k in ("component", "ladder", "class", "moment", "rule"): + if first_dup(x["id"] for x in P[k]) is not None: + no("policy-duplicate-id") + if P["lead"] not in [c["id"] for c in P["component"]]: + no("policy-lead") + s = P["segmentation"] + if not s["closed"]: + no("policy-open-gop") + if not (1 <= s["min_ms"] <= s["target_ms"] <= s["max_ms"]): + no("policy-piece-bounds") + comps = {c["id"] for c in P["component"]} + if any(r["component"] not in comps for r in P["ladder"]): + no("policy-ladder-component") + if any((r["role"] == "refinement") != ("refines" in r) for r in P["ladder"]): + no("policy-refines-role") + by = {r["id"]: r for r in P["ladder"]} + if any("refines" in r and r["refines"] not in by for r in P["ladder"]): + no("policy-dangling-refines") + for r in P["ladder"]: + seen, cur = {r["id"]}, r + while "refines" in cur: + if cur["refines"] in seen: + no("policy-refines-cycle") + seen.add(cur["refines"]) + cur = by[cur["refines"]] + for r in P["ladder"]: + if r["role"] != "refinement": + continue + cur = r + while "refines" in cur: + cur = by[cur["refines"]] + if cur["component"] != r["component"]: + no("policy-refines-base") + if cur["role"] != "base": + no("policy-refines-base") + if first_dup(r["refines"] for r in P["ladder"] if "refines" in r) is not None: + no("policy-refines-branch") + for c in P["component"]: + if not any(r["component"] == c["id"] and r["role"] == "base" for r in P["ladder"]): + no("policy-no-base") + n = max(layers(P).values(), default=0) + 1 + if any(c["max_layer"] > n - 1 for c in P["class"]): + no("policy-class-layer") + if any(r["then"].get("max_layer", 0) > n - 1 for r in P["rule"]): + no("policy-class-layer") + for r in P["rule"]: + if "ladder" in r["then"]: + rs = [by.get(i) for i in r["then"]["ladder"]] + ok = rs and all(rs) and all(x["component"] == rs[0]["component"] for x in rs) \ + and any(x["role"] == "base" for x in rs) + if not ok: + no("policy-rule-ladder") + lag = [0] * n if lag is None else lag + if len(lag) != n or lag[0] != 0 or any(b < a for a, b in zip(lag, lag[1:])): + no("policy-layer-lag") + P["live"] = {"layer_lag_ms": lag} + deadlines = {r["then"]["deadline_ms"] for r in P["rule"] if "deadline_ms" in r["then"]} + deadlines.add(P["defaults"]["deadline_ms"]) + if len(deadlines) > 4: + no("policy-too-many-deadlines") + for d in deadlines: + if any(d < x + 1 for x in lag[1:]): + no("policy-deadline-unreachable") + return P + + +def parse_policy(text): + try: + doc = tomllib.loads(text) + except tomllib.TOMLDecodeError: + raise Refused("policy-toml") + for k in sorted(doc): + kinds(doc[k], k) + P, lag = read_policy(doc) + return constrain(P, lag) + + +def digest(P): + return blake3(b"wai:pack-policy\x01" + jcs(P).encode()).hex() + + +# ---------------------------------------------------------------- sources (§4.7) + + +def clip(v): + return 0 if v < 0 else 255 if v > 255 else v + + +def bt709(y, cb, cr): + y, cb, cr = y - 16, cb - 128, cr - 128 + return (clip((298 * y + 459 * cr + 128) >> 8), clip((298 * y - 55 * cb - 136 * cr + 128) >> 8), + clip((298 * y + 541 * cb + 128) >> 8)) + + +def line(b, pos, at_end): + """A line of at most 1024 bytes and its newline; None at the end of the bytes.""" + if pos >= len(b): + return None, pos + nl = b.find(b"\n", pos, pos + 1025) + if nl < 0: + raise Refused(at_end) + return b[pos:nl], nl + 1 + + +def u32_digits(s): + if not s or len(s) > 10 or not s.isdigit(): + return None + v = int(s) + return v if v <= U32 else None + + +def read_y4m(b): + """(w, h, rate, chroma, frames as RGB8 bytes).""" + l, pos = line(b, 0, "y4m-header") + if l is None: + raise Refused("y4m-header") + parts = l.split(b" ") + if parts[0] != b"YUV4MPEG2": + raise Refused("y4m-header") + w = h = rate = None + chroma = "420" + for p in parts[1:]: + if not p: + continue + tag, v = p[:1], p[1:] + if tag == b"W" or tag == b"H": + x = u32_digits(v) + if x is None: + raise Refused("y4m-header") + if tag == b"W": + w = x + else: + h = x + elif tag == b"F": + nd = v.split(b":") + n = u32_digits(nd[0]) if len(nd) == 2 else None + d = u32_digits(nd[1]) if len(nd) == 2 else None + if not n or not d: + raise Refused("y4m-header") + rate = (n, d) + elif tag == b"I" and v != b"p": + raise Refused("y4m-interlaced") + elif tag == b"C": + if v in (b"420jpeg", b"420paldv", b"420mpeg2", b"420"): + chroma = "420" + elif v == b"444": + chroma = "444" + else: + raise Refused("y4m-colorspace") + if not w or not h or w * h > (1 << 26) // 3: + raise Refused("y4m-header") + cw, ch = (w, h) if chroma == "444" else ((w + 1) // 2, (h + 1) // 2) + size = w * h + 2 * cw * ch + frames = [] + while True: + l, pos = line(b, pos, "y4m-truncated") + if l is None: + break + parts = l.split(b" ") + if parts[0] != b"FRAME": + raise Refused("y4m-truncated") + for p in parts[1:]: + if p[:1] in (b"W", b"H", b"C"): + raise Refused("y4m-size-change") + if p[:1] == b"I" and p[1:] != b"p": + raise Refused("y4m-interlaced") + if pos + size > len(b): + raise Refused("y4m-truncated") + yp, up, vp = b[pos:pos + w * h], b[pos + w * h:pos + w * h + cw * ch], b[pos + w * h + cw * ch:pos + size] + pos += size + rgb = bytearray() + for y in range(h): + for x in range(w): + ci = y * w + x if chroma == "444" else (y >> 1) * cw + (x >> 1) + rgb.extend(bt709(yp[y * w + x], up[ci], vp[ci])) + frames.append(bytes(rgb)) + return w, h, rate, chroma, frames + + +def read_wav(b): + if len(b) < 12 or b[0:4] != b"RIFF" or b[8:12] != b"WAVE": + raise Refused("wav-format") + pos, fmt = 12, None + while True: + if pos + 8 > len(b): + raise Refused("wav-truncated") + cid, ln = b[pos:pos + 4], int.from_bytes(b[pos + 4:pos + 8], "little") + pos += 8 + if cid == b"fmt ": + if not 16 <= ln <= 40: + raise Refused("wav-format") + if pos + ln > len(b): + raise Refused("wav-truncated") + f = b[pos:pos + ln] + tag, ch, rate = int.from_bytes(f[0:2], "little"), int.from_bytes(f[2:4], "little"), int.from_bytes(f[4:8], "little") + align, bits = int.from_bytes(f[12:14], "little"), int.from_bytes(f[14:16], "little") + if tag != 1 or bits != 16 or ch == 0 or rate == 0 or align != 2 * ch: + raise Refused("wav-format") + fmt = (ch, rate) + pos += ln + (ln & 1) + elif cid == b"data": + if fmt is None or ln % (2 * fmt[0]): + raise Refused("wav-format") + if pos + ln > len(b): + raise Refused("wav-truncated") + return fmt, b[pos:pos + ln] + else: + pos += ln + (ln & 1) + if pos > len(b): + raise Refused("wav-truncated") + + +def source_verdict(name, b): + try: + read_y4m(b) if name.endswith(".y4m") else read_wav(b) + return "ok" + except Refused as r: + return r.code + + +# ---------------------------------------------------------------- unit conversions (§4.4, §4.5) + + +U64 = (1 << 64) - 1 + + +def units_from_ms(ms, num, den): + return min(max(ms * num // (1000 * den), 1), U64) + + +def align(start, lead, comp): + return min(start * comp[0] * lead[1] // (comp[1] * lead[0]), U64) + + +# ---------------------------------------------------------------- fixtures + + +M32 = 0xFFFFFFFF + + +def mix(v): + v ^= v >> 16 + v = (v * 0x7FEB352D) & M32 + v ^= v >> 15 + v = (v * 0x846CA68B) & M32 + v ^= v >> 16 + return v + + +def noise(seed, x, y, t): + return mix(seed ^ mix(x ^ mix(y ^ mix(t)))) + + +def ramp(lo, hi, dx, dy, speed, jitter, seed, cb, cr): + def y_of(x, y, t): + v = (x * dx + y * dy + t * speed) % 32 + base = lo + v * (hi - lo) // 31 + if jitter: + return min(max(max(base + noise(seed, x, y, t) % 3 - 1, 0), 16), 235) + return base + return y_of, (cb, cr) + + +def blocks(levels, bw, bh, speed, cb, cr): + return (lambda x, y, t: levels[((x + t * speed) // bw + y // bh) % 3]), (cb, cr) + + +def noise_shot(lo, hi, seed, cb, cr): + return (lambda x, y, t: lo + noise(seed, x, y, t) % (hi - lo + 1)), (cb, cr) + + +def fixture_spans(name): + if name == "cuts-01": + a = ramp(20, 100, 1, 1, 1, True, 1, 110, 140) + b = ramp(120, 220, 2, 1, 1, True, 2, 150, 120) + flash = noise_shot(228, 235, 3, 128, 128) + c = ramp(30, 90, 1, 2, 1, True, 4, 120, 120) + d = ramp(150, 230, 1, 0, 2, True, 5, 136, 136) + e = ramp(40, 120, 0, 1, 1, True, 6, 100, 150) + f = ramp(140, 200, 1, 0, 1, True, 7, 160, 110) + return 32, 18, [(60, a, None), (30, b, None), (6, flash, None), (44, b, None), (20, c, None), (40, c, d), + (30, d, None), (30, e, None), (30, f, None), (30, e, None), (30, f, None)] + if name == "kinds-01": + return 32, 18, [(40, ramp(20, 200, 1, 1, 0, False, 0, 128, 128), None), + (40, blocks([40, 120, 200], 8, 6, 1, 128, 128), None), + (40, noise_shot(16, 235, 7, 128, 128), None), + (40, ramp(16, 80, 1, 1, 1, True, 8, 128, 128), None), + (130, ramp(60, 180, 2, 1, 0, False, 0, 140, 116), None)] + return None + + +def fixture(name): + w, h, spans = fixture_spans(name) + cw, ch = (w + 1) // 2, (h + 1) // 2 + out = bytearray(f"YUV4MPEG2 W{w} H{h} F25:1 Ip A1:1 C420jpeg\n".encode()) + t = 0 + for n, (y_of, chroma), to in spans: + for i in range(n): + out += b"FRAME\n" + def blend(a, b): + return (a * (n - i - 1) + b * (i + 1)) // n + for y in range(h): + for x in range(w): + v = y_of(x, y, t) + out.append(blend(v, to[0](x, y, t)) if to else v) + cb, cr = chroma + if to: + cb, cr = blend(cb, to[1][0]), blend(cr, to[1][1]) + out += bytes([cb]) * (cw * ch) + bytes([cr]) * (cw * ch) + t += 1 + return bytes(out) + + +# ---------------------------------------------------------------- scene cut (§4.3) and kinds (§4.6) + + +def luma(r, g, b): + return (77 * r + 150 * g + 29 * b + 128) >> 8 + + +def distance(a, b, wh): + return sum(abs(x - y) for x, y in zip(a, b)) * 1000 // (2 * wh) + + +def segment(frames, w, h, p): + """The on-demand form: every frame known, `last` the final index.""" + wh = w * h + ys = [[luma(f[i], f[i + 1], f[i + 2]) for i in range(0, len(f), 3)] for f in frames] + hists = [] + for y in ys: + hh = [0] * 64 + for v in y: + hh[v >> 2] += 1 + hists.append(hh) + n = len(frames) + last = n - 1 + scores = [0] + [distance(hists[f], hists[f - 1], wh) for f in range(1, n)] + L = p["lookahead"] + + def rejected(f): + if any(distance(hists[f + k], hists[f - 1], wh) < p["cut_abs"] for k in range(1, min(L, last - f) + 1)): + return True + return any(distance(hists[f], hists[f - 1 - j], wh) < p["cut_abs"] for j in range(1, min(L, f - 1) + 1)) + + starts, flags, window, start = [0], [0], [], 0 + for f in range(1, n): + ln, s = f - start, scores[f] + med = sorted(window)[(len(window) - 1) // 2] if window else 0 + hard = ln >= p["min"] and s >= p["cut_abs"] and (len(window) < 3 or s * 1000 >= p["cut_rel"] * med) + soft = ln >= p["target"] and 2 * s >= p["cut_abs"] + if (hard or soft) and not rejected(f): + flag = 1 + elif ln >= p["max"]: + flag = 2 + else: + flag = 0 + if flag: + starts.append(f) + flags.append(flag) + start, window = f, [] + else: + window = (window + [s])[-p["window"]:] + pieces = [] + for i, st in enumerate(starts): + end = starts[i + 1] if i + 1 < len(starts) else n + abs_diff = changed = 0 + for f in range(st + 1, end): + for a, b in zip(ys[f], ys[f - 1]): + abs_diff += abs(a - b) + changed += a != b + compared = wh * (end - st - 1) + pieces.append({"start": st, "len": end - st, "flags": flags[i], "abs_diff": abs_diff, "changed": changed, + "compared": compared, "occupancy": sum(1 for c in hists[st] if c)}) + return pieces, scores + + +def classify(pc, c): + mad = pc["abs_diff"] * 1000 // pc["compared"] if pc["compared"] else 0 + chg = pc["changed"] * 1000 // pc["compared"] if pc["compared"] else 0 + if mad <= c["static_mad_milli"] and chg <= c["static_permille"]: + kind = "static" + elif pc["occupancy"] <= c["synthetic_bins"]: + kind = "synthetic" + elif mad >= c["motion_high_milli"]: + kind = "motion_high" + else: + kind = "natural" + return kind, mad, chg + + +def plan_digest(starts): + b = b"wai:pack-plan\x01" + len(starts).to_bytes(8, "little") + for s in starts: + b += s.to_bytes(8, "little") + return blake3(b).hex() + + +# ---------------------------------------------------------------- the corpus + + +class Report: + def __init__(self): + self.failures = 0 + self.checked = 0 + + def check(self, ok, what): + self.checked += 1 + if not ok: + self.failures += 1 + print(f"FAIL {what}") + + +def load(path): + with open(path, "rb") as f: + return f.read() + + +def verify(d): + rep = Report() + pol = json.loads(load(os.path.join(d, "policy", "expected.json"))) + for c in pol["cases"]: + P = parse_policy(load(os.path.join(d, "policy", c["file"])).decode()) + if c.get("overrides", {}).get("royalty_free_only") is not None: + P["royalty_free_only"] = c["overrides"]["royalty_free_only"] + rep.check(jcs(P) == c["canonical"], f"policy/{c['file']}: canonical JSON") + rep.check(digest(P) == c["digest"], f"policy/{c['file']}: digest") + names = sorted(n for n in os.listdir(os.path.join(d, "policy")) if n.endswith(".toml")) + rep.check(sorted({c["file"] for c in pol["cases"]}) == names, "policy/: every policy is a case") + for a, b in pol["equal"]: + da, db = (digest(parse_policy(load(os.path.join(d, "policy", f)).decode())) for f in (a, b)) + rep.check(da == db, f"policy/{a} and {b}: one policy, one digest") + + ref = json.loads(load(os.path.join(d, "refusals", "expected.json"))) + listed = set() + for c in ref["cases"]: + listed.add(c["file"]) + b = load(os.path.join(d, "refusals", c["file"])) + if c["file"].startswith("policy/"): + try: + parse_policy(b.decode()) + got = "ok" + except Refused as r: + got = r.code + else: + got = source_verdict(c["file"], b) + rep.check(got == c["reason"] and got != "ok", f"refusals/{c['file']}: {got}, the corpus says {c['reason']}") + for sub in ("policy", "source"): + for n in os.listdir(os.path.join(d, "refusals", sub)): + rep.check(f"{sub}/{n}" in listed, f"refusals/{sub}/{n} is a case") + + conv = json.loads(load(os.path.join(d, "conversions.json"))) + for c in conv["ms_to_units"]: + rep.check(units_from_ms(c["ms"], c["num"], c["den"]) == c["units"], f"ms_to_units {c}") + for c in conv["align"]: + rep.check(align(c["start"], c["lead"], c["component"]) == c["units"], f"align {c}") + for c in conv["y4m_rgb"]: + _, _, _, _, frames = read_y4m(load(os.path.join(d, c["file"]))) + rep.check([hashlib.sha256(f).hexdigest() for f in frames] == c["rgb_sha256"], f"{c['file']}: RGB8 frames") + + sc = json.loads(load(os.path.join(d, "scenecut", "expected.json"))) + P = parse_policy(load(os.path.join(d, "scenecut", sc["policy"])).decode()) + comp = next(c for c in P["component"] if c["id"] == sc["component"]) + tb = (comp["timebase"]["num"], comp["timebase"]["den"]) + s = P["segmentation"] + p = {"min": units_from_ms(s["min_ms"], *tb), "target": units_from_ms(s["target_ms"], *tb), + "max": units_from_ms(s["max_ms"], *tb), "cut_abs": s["cut_abs_permille"], "cut_rel": s["cut_rel_permille"], + "window": s["cut_window"], "lookahead": s["lookahead_frames"]} + for fx in sc["fixtures"]: + b = fixture(fx["name"]) + rep.check(hashlib.sha256(b).hexdigest() == fx["sha256"], f"fixture {fx['name']}: SHA-256") + w, h, rate, _, frames = read_y4m(b) + rep.check(len(frames) == fx["frames"] and rate == (25, 1), f"fixture {fx['name']}: frames and rate") + pieces, scores = segment(frames, w, h, p) + rep.check(scores == fx["scores"], f"scenecut {fx['name']}: scores") + got = [] + for pc in pieces: + kind, mad, chg = classify(pc, P["classify"]) + got.append({"start": pc["start"], "len": pc["len"], "flags": pc["flags"], "kind": kind, + "mad_milli": mad, "changed_permille": chg, "occupancy": pc["occupancy"]}) + rep.check(got == fx["pieces"], f"scenecut {fx['name']}: pieces") + rep.check(plan_digest([x["start"] for x in got]) == fx["plan_digest"], f"scenecut {fx['name']}: plan digest") + return rep + + +def main(argv): + if len(argv) != 2: + print(__doc__) + return 2 + rep = verify(argv[1]) + print(f"pack_verify: {rep.checked - rep.failures}/{rep.checked} checks hold") + return 0 if rep.failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/wai/pack-conformance/policy/eq-defaults-a.toml b/wai/pack-conformance/policy/eq-defaults-a.toml new file mode 100644 index 000000000..331bbc0c6 --- /dev/null +++ b/wai/pack-conformance/policy/eq-defaults-a.toml @@ -0,0 +1,15 @@ +# Every member left to its default. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/policy/eq-defaults-b.toml b/wai/pack-conformance/policy/eq-defaults-b.toml new file mode 100644 index 000000000..d85466969 --- /dev/null +++ b/wai/pack-conformance/policy/eq-defaults-b.toml @@ -0,0 +1,43 @@ +# eq-defaults-a.toml with every default written out: one effective policy. +schema = "wai.pack.policy/1" +asset_id = "r" +royalty_free_only = false +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[segmentation] +target_ms = 2000 +min_ms = 1000 +max_ms = 4000 +scene_cut = "wai.pack.scenecut/1" +cut_abs_permille = 300 +cut_rel_permille = 3000 +cut_window = 8 +lookahead_frames = 6 +closed = true + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" +params = { block = 16, search = 7, mv_precision = 2, q_candidates = [8], max_sse_px_milli = 0 } + +[floor] +extra_keyframes = false + +[live] +layer_lag_ms = [0] + +[defaults] +deadline_ms = 1500 + +[classify] +static_permille = 5 +static_mad_milli = 300 +synthetic_bins = 12 +motion_high_milli = 9000 diff --git a/wai/pack-conformance/policy/eq-spelling-a.toml b/wai/pack-conformance/policy/eq-spelling-a.toml new file mode 100644 index 000000000..0f54b0571 --- /dev/null +++ b/wai/pack-conformance/policy/eq-spelling-a.toml @@ -0,0 +1,40 @@ +# Standard tables, one member per line. +schema = "wai.pack.policy/1" +asset_id = "spelling" +lead = "v" + +[[component]] +id = "v" +media = "video" + +[component.timebase] +num = 25 +den = 1 + +[segmentation] +target_ms = 3000 +lookahead_frames = 4 + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[ladder.params] +block = 8 +q_candidates = [12, 6] + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" + +[ladder.params] +q = 4 + +[[class]] +id = "tv" +max_layer = 1 diff --git a/wai/pack-conformance/policy/eq-spelling-b.toml b/wai/pack-conformance/policy/eq-spelling-b.toml new file mode 100644 index 000000000..01ac1d0e9 --- /dev/null +++ b/wai/pack-conformance/policy/eq-spelling-b.toml @@ -0,0 +1,32 @@ +# eq-spelling-a.toml spelled otherwise: inline tables, dotted keys, members in +# another order, literal strings, a hexadecimal and an underscored integer, comments. +lead = 'v' # the lead component +schema = "wai.pack.policy/1" +asset_id = "spel\u006cing" +segmentation.lookahead_frames = 0x4 +segmentation.target_ms = 3_000 + +[[component]] +timebase = { den = 1, num = 25 } +media = "video" +id = "v" + +[[ladder]] +role = "base" +params.q_candidates = [ 12, 6, ] +params.block = 0o10 +capability = "wai.video.int_motion" +component = "v" +id = "b0" + +[[ladder]] +params = { q = 4 } +refines = "b0" +role = "refinement" +capability = 'wai.video.int_refine' +component = "v" +id = "r1" + +[[class]] +max_layer = 1 +id = "tv" diff --git a/wai/pack-conformance/policy/example.toml b/wai/pack-conformance/policy/example.toml new file mode 100644 index 000000000..4b6276a8c --- /dev/null +++ b/wai/pack-conformance/policy/example.toml @@ -0,0 +1,145 @@ +# The example policy of extensions/packaging.md §3.3: two components (video lead, +# audio), a staged video ladder with a still-image rung and a supplied floor, a +# layered audio ladder, three classes, one moment and three rules. +schema = "wai.pack.policy/1" +asset_id = "pack-fixture-01" +royalty_free_only = true +lead = "v" + +[[component]] +id = "v" +media = "video" +source = "src/video.y4m" + +[component.timebase] +num = 25 +den = 1 + +[[component]] +id = "a" +media = "audio" +source = "src/audio.wav" + +[component.timebase] +num = 48000 +den = 1 + +[segmentation] +target_ms = 2000 +min_ms = 1000 +max_ms = 4000 +scene_cut = "wai.pack.scenecut/1" +cut_abs_permille = 300 +cut_rel_permille = 3000 +cut_window = 8 +lookahead_frames = 6 +closed = true + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" +params = { block = 16, search = 7, mv_precision = 2, q_candidates = [16, 12, 8], max_sse_px_milli = 40000 } + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" +params = { q = 6 } + +[[ladder]] +id = "r2" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "r1" +params = { q = 3 } + +[[ladder]] +id = "still" +component = "v" +capability = "wai.image.png" +role = "base" +params = {} + +[[ladder]] +id = "f0" +component = "v" +capability = "wai.video.av1" +role = "floor" +params = { source = "supplied" } + +[[ladder]] +id = "ab" +component = "a" +capability = "wai.audio.int_layered" +role = "base" +params = { rate = 8000 } + +[[ladder]] +id = "ar1" +component = "a" +capability = "wai.audio.int_refine" +role = "refinement" +refines = "ab" +params = { to_rate = 48000, q = 64 } + +[[ladder]] +id = "af" +component = "a" +capability = "wai.audio.flac" +role = "floor" +params = { source = "encode" } + +[floor] +supplied_listing = "0000000000000000000000000000000000000000000000000000000000000000" +extra_keyframes = false + +[live] +layer_lag_ms = [0, 200, 400] + +[[class]] +id = "mobile" +max_layer = 1 +max_kbps = 900 + +[[class]] +id = "tv" +max_layer = 2 + +[[class]] +id = "legacy" +floor_only = true + +[[moment]] +id = "m-goal" +from_ms = 30000 +to_ms = 34000 +tags = ["event"] + +[[rule]] +id = "static" +when = { piece_kind = ["static"] } +then = { ladder = ["still", "f0"] } + +[[rule]] +id = "motion" +when = { piece_kind = ["motion_high"] } +then = { max_layer = 1 } + +[[rule]] +id = "event" +when = { moment_tag = ["event"] } +then = { max_layer = 2, deadline_ms = 800 } + +[defaults] +deadline_ms = 1500 + +[classify] +static_permille = 5 +static_mad_milli = 300 +synthetic_bins = 12 +motion_high_milli = 9000 diff --git a/wai/pack-conformance/policy/expected.json b/wai/pack-conformance/policy/expected.json new file mode 100644 index 000000000..28210d924 --- /dev/null +++ b/wai/pack-conformance/policy/expected.json @@ -0,0 +1,47 @@ +{ + "cases": [ + { + "file": "eq-defaults-a.toml", + "canonical": "{\"asset_id\":\"r\",\"class\":[],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[8],\"search\":7},\"role\":\"base\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", + "digest": "5b195d09af5103475411ebd2d438f91e66b63bae230d2c2eafd27e7b99496f87" + }, + { + "file": "eq-defaults-b.toml", + "canonical": "{\"asset_id\":\"r\",\"class\":[],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[8],\"search\":7},\"role\":\"base\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", + "digest": "5b195d09af5103475411ebd2d438f91e66b63bae230d2c2eafd27e7b99496f87" + }, + { + "file": "eq-spelling-a.toml", + "canonical": "{\"asset_id\":\"spelling\",\"class\":[{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":1}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":8,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[12,6],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":4},\"refines\":\"b0\",\"role\":\"refinement\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":4,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":3000}}", + "digest": "d6bc57dde125d97a04eb8f18007bae4072d5a392fb3e60de18cb7d6e235aa2f8" + }, + { + "file": "eq-spelling-b.toml", + "canonical": "{\"asset_id\":\"spelling\",\"class\":[{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":1}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":8,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[12,6],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":4},\"refines\":\"b0\",\"role\":\"refinement\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":4,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":3000}}", + "digest": "d6bc57dde125d97a04eb8f18007bae4072d5a392fb3e60de18cb7d6e235aa2f8" + }, + { + "file": "example.toml", + "canonical": "{\"asset_id\":\"pack-fixture-01\",\"class\":[{\"floor_only\":false,\"id\":\"mobile\",\"max_kbps\":900,\"max_layer\":1},{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":2},{\"floor_only\":true,\"id\":\"legacy\",\"max_layer\":0}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"source\":\"src/video.y4m\",\"timebase\":{\"den\":1,\"num\":25}},{\"id\":\"a\",\"media\":\"audio\",\"source\":\"src/audio.wav\",\"timebase\":{\"den\":1,\"num\":48000}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false,\"supplied_listing\":\"0000000000000000000000000000000000000000000000000000000000000000\"},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":40000,\"mv_precision\":2,\"q_candidates\":[16,12,8],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":6},\"refines\":\"b0\",\"role\":\"refinement\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r2\",\"params\":{\"q\":3},\"refines\":\"r1\",\"role\":\"refinement\"},{\"capability\":\"wai.image.png\",\"component\":\"v\",\"id\":\"still\",\"params\":{},\"role\":\"base\"},{\"capability\":\"wai.video.av1\",\"component\":\"v\",\"id\":\"f0\",\"params\":{\"source\":\"supplied\"},\"role\":\"floor\"},{\"capability\":\"wai.audio.int_layered\",\"component\":\"a\",\"id\":\"ab\",\"params\":{\"rate\":8000},\"role\":\"base\"},{\"capability\":\"wai.audio.int_refine\",\"component\":\"a\",\"id\":\"ar1\",\"params\":{\"q\":64,\"to_rate\":48000},\"refines\":\"ab\",\"role\":\"refinement\"},{\"capability\":\"wai.audio.flac\",\"component\":\"a\",\"id\":\"af\",\"params\":{\"source\":\"encode\"},\"role\":\"floor\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,200,400]},\"moment\":[{\"from_ms\":30000,\"id\":\"m-goal\",\"tags\":[\"event\"],\"to_ms\":34000}],\"royalty_free_only\":true,\"rule\":[{\"id\":\"static\",\"then\":{\"ladder\":[\"still\",\"f0\"]},\"when\":{\"piece_kind\":[\"static\"]}},{\"id\":\"motion\",\"then\":{\"max_layer\":1},\"when\":{\"piece_kind\":[\"motion_high\"]}},{\"id\":\"event\",\"then\":{\"deadline_ms\":800,\"max_layer\":2},\"when\":{\"moment_tag\":[\"event\"]}}],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", + "digest": "a09e8894b5f90e4a38334ddc0a20f3b253a712f1b5b3f5265e31d6e6ea098754" + }, + { + "file": "example.toml", + "canonical": "{\"asset_id\":\"pack-fixture-01\",\"class\":[{\"floor_only\":false,\"id\":\"mobile\",\"max_kbps\":900,\"max_layer\":1},{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":2},{\"floor_only\":true,\"id\":\"legacy\",\"max_layer\":0}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"source\":\"src/video.y4m\",\"timebase\":{\"den\":1,\"num\":25}},{\"id\":\"a\",\"media\":\"audio\",\"source\":\"src/audio.wav\",\"timebase\":{\"den\":1,\"num\":48000}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false,\"supplied_listing\":\"0000000000000000000000000000000000000000000000000000000000000000\"},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":40000,\"mv_precision\":2,\"q_candidates\":[16,12,8],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":6},\"refines\":\"b0\",\"role\":\"refinement\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r2\",\"params\":{\"q\":3},\"refines\":\"r1\",\"role\":\"refinement\"},{\"capability\":\"wai.image.png\",\"component\":\"v\",\"id\":\"still\",\"params\":{},\"role\":\"base\"},{\"capability\":\"wai.video.av1\",\"component\":\"v\",\"id\":\"f0\",\"params\":{\"source\":\"supplied\"},\"role\":\"floor\"},{\"capability\":\"wai.audio.int_layered\",\"component\":\"a\",\"id\":\"ab\",\"params\":{\"rate\":8000},\"role\":\"base\"},{\"capability\":\"wai.audio.int_refine\",\"component\":\"a\",\"id\":\"ar1\",\"params\":{\"q\":64,\"to_rate\":48000},\"refines\":\"ab\",\"role\":\"refinement\"},{\"capability\":\"wai.audio.flac\",\"component\":\"a\",\"id\":\"af\",\"params\":{\"source\":\"encode\"},\"role\":\"floor\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,200,400]},\"moment\":[{\"from_ms\":30000,\"id\":\"m-goal\",\"tags\":[\"event\"],\"to_ms\":34000}],\"royalty_free_only\":false,\"rule\":[{\"id\":\"static\",\"then\":{\"ladder\":[\"still\",\"f0\"]},\"when\":{\"piece_kind\":[\"static\"]}},{\"id\":\"motion\",\"then\":{\"max_layer\":1},\"when\":{\"piece_kind\":[\"motion_high\"]}},{\"id\":\"event\",\"then\":{\"deadline_ms\":800,\"max_layer\":2},\"when\":{\"moment_tag\":[\"event\"]}}],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", + "digest": "48177936de4431ad8e64104431b131bf1670dfb549476dda81f547185ce22e18", + "overrides": { + "royalty_free_only": false + } + } + ], + "equal": [ + [ + "eq-defaults-a.toml", + "eq-defaults-b.toml" + ], + [ + "eq-spelling-a.toml", + "eq-spelling-b.toml" + ] + ] +} diff --git a/wai/pack-conformance/refusals/expected.json b/wai/pack-conformance/refusals/expected.json new file mode 100644 index 000000000..589f322fe --- /dev/null +++ b/wai/pack-conformance/refusals/expected.json @@ -0,0 +1,168 @@ +{ + "cases": [ + { + "file": "policy/class-layer.toml", + "reason": "policy-class-layer" + }, + { + "file": "policy/dangling-refines.toml", + "reason": "policy-dangling-refines" + }, + { + "file": "policy/datetime.toml", + "reason": "policy-datetime" + }, + { + "file": "policy/deadline-unreachable.toml", + "reason": "policy-deadline-unreachable" + }, + { + "file": "policy/duplicate-id.toml", + "reason": "policy-duplicate-id" + }, + { + "file": "policy/float.toml", + "reason": "policy-float" + }, + { + "file": "policy/integer-range.toml", + "reason": "policy-integer-range" + }, + { + "file": "policy/ladder-component.toml", + "reason": "policy-ladder-component" + }, + { + "file": "policy/layer-lag.toml", + "reason": "policy-layer-lag" + }, + { + "file": "policy/lead.toml", + "reason": "policy-lead" + }, + { + "file": "policy/missing-key.toml", + "reason": "policy-missing-key" + }, + { + "file": "policy/negative.toml", + "reason": "policy-negative" + }, + { + "file": "policy/no-base.toml", + "reason": "policy-no-base" + }, + { + "file": "policy/open-gop.toml", + "reason": "policy-open-gop" + }, + { + "file": "policy/piece-bounds.toml", + "reason": "policy-piece-bounds" + }, + { + "file": "policy/refines-base.toml", + "reason": "policy-refines-base" + }, + { + "file": "policy/refines-branch.toml", + "reason": "policy-refines-branch" + }, + { + "file": "policy/refines-cycle.toml", + "reason": "policy-refines-cycle" + }, + { + "file": "policy/refines-role.toml", + "reason": "policy-refines-role" + }, + { + "file": "policy/rule-ladder.toml", + "reason": "policy-rule-ladder" + }, + { + "file": "policy/schema.toml", + "reason": "policy-schema" + }, + { + "file": "policy/toml-duplicate-key.toml", + "reason": "policy-toml" + }, + { + "file": "policy/toml.toml", + "reason": "policy-toml" + }, + { + "file": "policy/too-many-components.toml", + "reason": "policy-too-many-components" + }, + { + "file": "policy/too-many-deadlines.toml", + "reason": "policy-too-many-deadlines" + }, + { + "file": "policy/too-many-moments.toml", + "reason": "policy-too-many-moments" + }, + { + "file": "policy/too-many-rules.toml", + "reason": "policy-too-many-rules" + }, + { + "file": "policy/type.toml", + "reason": "policy-type" + }, + { + "file": "policy/unknown-key-params.toml", + "reason": "policy-unknown-key" + }, + { + "file": "policy/unknown-key.toml", + "reason": "policy-unknown-key" + }, + { + "file": "policy/value-block.toml", + "reason": "policy-value" + }, + { + "file": "policy/value-media.toml", + "reason": "policy-value" + }, + { + "file": "source/y4m-header.y4m", + "reason": "y4m-header" + }, + { + "file": "source/y4m-interlaced.y4m", + "reason": "y4m-interlaced" + }, + { + "file": "source/y4m-interlaced-frame.y4m", + "reason": "y4m-interlaced" + }, + { + "file": "source/y4m-colorspace.y4m", + "reason": "y4m-colorspace" + }, + { + "file": "source/y4m-size-change.y4m", + "reason": "y4m-size-change" + }, + { + "file": "source/y4m-truncated.y4m", + "reason": "y4m-truncated" + }, + { + "file": "source/wav-format-float.wav", + "reason": "wav-format" + }, + { + "file": "source/wav-format-8bit.wav", + "reason": "wav-format" + }, + { + "file": "source/wav-truncated.wav", + "reason": "wav-truncated" + } + ] +} diff --git a/wai/pack-conformance/refusals/policy/class-layer.toml b/wai/pack-conformance/refusals/policy/class-layer.toml new file mode 100644 index 000000000..136b935bf --- /dev/null +++ b/wai/pack-conformance/refusals/policy/class-layer.toml @@ -0,0 +1,19 @@ +# Refused: a class layer no rung reaches. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[class]] +id = "tv" +max_layer = 1 diff --git a/wai/pack-conformance/refusals/policy/dangling-refines.toml b/wai/pack-conformance/refusals/policy/dangling-refines.toml new file mode 100644 index 000000000..b499d2b2e --- /dev/null +++ b/wai/pack-conformance/refusals/policy/dangling-refines.toml @@ -0,0 +1,23 @@ +# Refused: refines names no rung. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "nope" +params = { q = 4 } diff --git a/wai/pack-conformance/refusals/policy/datetime.toml b/wai/pack-conformance/refusals/policy/datetime.toml new file mode 100644 index 000000000..0b38cd87d --- /dev/null +++ b/wai/pack-conformance/refusals/policy/datetime.toml @@ -0,0 +1,20 @@ +# Refused: a date-time. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[moment]] +id = "m" +from_ms = 1979-05-27T07:32:00Z +to_ms = 5 diff --git a/wai/pack-conformance/refusals/policy/deadline-unreachable.toml b/wai/pack-conformance/refusals/policy/deadline-unreachable.toml new file mode 100644 index 000000000..0a15bf0a5 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/deadline-unreachable.toml @@ -0,0 +1,29 @@ +# Refused: a deadline layer 1 cannot meet. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" +params = { q = 4 } + +[live] +layer_lag_ms = [0, 300] + +[defaults] +deadline_ms = 300 diff --git a/wai/pack-conformance/refusals/policy/duplicate-id.toml b/wai/pack-conformance/refusals/policy/duplicate-id.toml new file mode 100644 index 000000000..845deb40f --- /dev/null +++ b/wai/pack-conformance/refusals/policy/duplicate-id.toml @@ -0,0 +1,21 @@ +# Refused: two rungs named b0. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.image.png" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/float.toml b/wai/pack-conformance/refusals/policy/float.toml new file mode 100644 index 000000000..35a9ea778 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/float.toml @@ -0,0 +1,18 @@ +# Refused: a float. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[segmentation] +target_ms = 2000.0 diff --git a/wai/pack-conformance/refusals/policy/integer-range.toml b/wai/pack-conformance/refusals/policy/integer-range.toml new file mode 100644 index 000000000..2945d89af --- /dev/null +++ b/wai/pack-conformance/refusals/policy/integer-range.toml @@ -0,0 +1,18 @@ +# Refused: an integer past 2^53 - 1. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[segmentation] +max_ms = 9007199254740992 diff --git a/wai/pack-conformance/refusals/policy/ladder-component.toml b/wai/pack-conformance/refusals/policy/ladder-component.toml new file mode 100644 index 000000000..a56a17b01 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/ladder-component.toml @@ -0,0 +1,21 @@ +# Refused: a rung of a component the policy does not define. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "x0" +component = "x" +capability = "wai.image.png" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/layer-lag.toml b/wai/pack-conformance/refusals/policy/layer-lag.toml new file mode 100644 index 000000000..2d88186ef --- /dev/null +++ b/wai/pack-conformance/refusals/policy/layer-lag.toml @@ -0,0 +1,26 @@ +# Refused: a first layer lag that is not 0. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" +params = { q = 4 } + +[live] +layer_lag_ms = [100, 200] diff --git a/wai/pack-conformance/refusals/policy/lead.toml b/wai/pack-conformance/refusals/policy/lead.toml new file mode 100644 index 000000000..9b7953ee7 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/lead.toml @@ -0,0 +1,15 @@ +# Refused: a lead that names no component. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "x" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/missing-key.toml b/wai/pack-conformance/refusals/policy/missing-key.toml new file mode 100644 index 000000000..f54080164 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/missing-key.toml @@ -0,0 +1,14 @@ +# Refused: a component with no timebase. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/negative.toml b/wai/pack-conformance/refusals/policy/negative.toml new file mode 100644 index 000000000..ec7a9d36a --- /dev/null +++ b/wai/pack-conformance/refusals/policy/negative.toml @@ -0,0 +1,18 @@ +# Refused: a negative integer. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[defaults] +deadline_ms = -5 diff --git a/wai/pack-conformance/refusals/policy/no-base.toml b/wai/pack-conformance/refusals/policy/no-base.toml new file mode 100644 index 000000000..749dbc017 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/no-base.toml @@ -0,0 +1,26 @@ +# Refused: a component with no base rung. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[component]] +id = "a" +media = "audio" +timebase = { num = 48000, den = 1 } + +[[ladder]] +id = "af" +component = "a" +capability = "wai.audio.flac" +role = "floor" diff --git a/wai/pack-conformance/refusals/policy/open-gop.toml b/wai/pack-conformance/refusals/policy/open-gop.toml new file mode 100644 index 000000000..5e6b16a54 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/open-gop.toml @@ -0,0 +1,18 @@ +# Refused: pieces that are not closed. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[segmentation] +closed = false diff --git a/wai/pack-conformance/refusals/policy/piece-bounds.toml b/wai/pack-conformance/refusals/policy/piece-bounds.toml new file mode 100644 index 000000000..7e2cca573 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/piece-bounds.toml @@ -0,0 +1,18 @@ +# Refused: min_ms above target_ms. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[segmentation] +min_ms = 3000 diff --git a/wai/pack-conformance/refusals/policy/refines-base.toml b/wai/pack-conformance/refusals/policy/refines-base.toml new file mode 100644 index 000000000..8ec664cdc --- /dev/null +++ b/wai/pack-conformance/refusals/policy/refines-base.toml @@ -0,0 +1,29 @@ +# Refused: a refinement of a floor. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "f0" +component = "v" +capability = "wai.video.av1" +role = "floor" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "f0" +params = { q = 4 } diff --git a/wai/pack-conformance/refusals/policy/refines-branch.toml b/wai/pack-conformance/refusals/policy/refines-branch.toml new file mode 100644 index 000000000..b40dc7727 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/refines-branch.toml @@ -0,0 +1,31 @@ +# Refused: two refinements of one base. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" +params = { q = 3 } + +[[ladder]] +id = "r2" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" +params = { q = 4 } diff --git a/wai/pack-conformance/refusals/policy/refines-cycle.toml b/wai/pack-conformance/refusals/policy/refines-cycle.toml new file mode 100644 index 000000000..eaeba0c78 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/refines-cycle.toml @@ -0,0 +1,31 @@ +# Refused: two refinements that refine each other. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "r2" +params = { q = 4 } + +[[ladder]] +id = "r2" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "r1" +params = { q = 4 } diff --git a/wai/pack-conformance/refusals/policy/refines-role.toml b/wai/pack-conformance/refusals/policy/refines-role.toml new file mode 100644 index 000000000..20863a827 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/refines-role.toml @@ -0,0 +1,22 @@ +# Refused: a refinement that refines nothing. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +params = { q = 4 } diff --git a/wai/pack-conformance/refusals/policy/rule-ladder.toml b/wai/pack-conformance/refusals/policy/rule-ladder.toml new file mode 100644 index 000000000..5882304a9 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/rule-ladder.toml @@ -0,0 +1,25 @@ +# Refused: a rule ladder with no base. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "f0" +component = "v" +capability = "wai.video.av1" +role = "floor" + +[[rule]] +id = "x" +then = { ladder = ["f0"] } diff --git a/wai/pack-conformance/refusals/policy/schema.toml b/wai/pack-conformance/refusals/policy/schema.toml new file mode 100644 index 000000000..ff06874b1 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/schema.toml @@ -0,0 +1,15 @@ +# Refused: another schema. +schema = "wai.pack.policy/2" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/toml-duplicate-key.toml b/wai/pack-conformance/refusals/policy/toml-duplicate-key.toml new file mode 100644 index 000000000..de75978d7 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml-duplicate-key.toml @@ -0,0 +1,16 @@ +# Refused: not TOML (a key defined twice). +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/toml.toml b/wai/pack-conformance/refusals/policy/toml.toml new file mode 100644 index 000000000..72d1d424f --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml.toml @@ -0,0 +1,15 @@ +# Refused: not TOML (an unterminated string). +schema = "wai.pack.policy/1" +asset_id = "r +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/too-many-components.toml b/wai/pack-conformance/refusals/policy/too-many-components.toml new file mode 100644 index 000000000..56da5945f --- /dev/null +++ b/wai/pack-conformance/refusals/policy/too-many-components.toml @@ -0,0 +1,55 @@ +# Refused: nine components. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[component]] +id = "c0" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c1" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c2" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c3" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c4" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c5" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c6" +media = "video" +timebase = { num = 25, den = 1 } + +[[component]] +id = "c7" +media = "video" +timebase = { num = 25, den = 1 } diff --git a/wai/pack-conformance/refusals/policy/too-many-deadlines.toml b/wai/pack-conformance/refusals/policy/too-many-deadlines.toml new file mode 100644 index 000000000..810816194 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/too-many-deadlines.toml @@ -0,0 +1,39 @@ +# Refused: five distinct deadline values. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[ladder]] +id = "r1" +component = "v" +capability = "wai.video.int_refine" +role = "refinement" +refines = "b0" +params = { q = 4 } + +[[rule]] +id = "d0" +then = { deadline_ms = 100 } + +[[rule]] +id = "d1" +then = { deadline_ms = 200 } + +[[rule]] +id = "d2" +then = { deadline_ms = 300 } + +[[rule]] +id = "d3" +then = { deadline_ms = 400 } diff --git a/wai/pack-conformance/refusals/policy/too-many-moments.toml b/wai/pack-conformance/refusals/policy/too-many-moments.toml new file mode 100644 index 000000000..e331e60a6 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/too-many-moments.toml @@ -0,0 +1,180 @@ +# Refused: 33 moments. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[moment]] +id = "m0" +from_ms = 0 +to_ms = 1 + +[[moment]] +id = "m1" +from_ms = 1 +to_ms = 2 + +[[moment]] +id = "m2" +from_ms = 2 +to_ms = 3 + +[[moment]] +id = "m3" +from_ms = 3 +to_ms = 4 + +[[moment]] +id = "m4" +from_ms = 4 +to_ms = 5 + +[[moment]] +id = "m5" +from_ms = 5 +to_ms = 6 + +[[moment]] +id = "m6" +from_ms = 6 +to_ms = 7 + +[[moment]] +id = "m7" +from_ms = 7 +to_ms = 8 + +[[moment]] +id = "m8" +from_ms = 8 +to_ms = 9 + +[[moment]] +id = "m9" +from_ms = 9 +to_ms = 10 + +[[moment]] +id = "m10" +from_ms = 10 +to_ms = 11 + +[[moment]] +id = "m11" +from_ms = 11 +to_ms = 12 + +[[moment]] +id = "m12" +from_ms = 12 +to_ms = 13 + +[[moment]] +id = "m13" +from_ms = 13 +to_ms = 14 + +[[moment]] +id = "m14" +from_ms = 14 +to_ms = 15 + +[[moment]] +id = "m15" +from_ms = 15 +to_ms = 16 + +[[moment]] +id = "m16" +from_ms = 16 +to_ms = 17 + +[[moment]] +id = "m17" +from_ms = 17 +to_ms = 18 + +[[moment]] +id = "m18" +from_ms = 18 +to_ms = 19 + +[[moment]] +id = "m19" +from_ms = 19 +to_ms = 20 + +[[moment]] +id = "m20" +from_ms = 20 +to_ms = 21 + +[[moment]] +id = "m21" +from_ms = 21 +to_ms = 22 + +[[moment]] +id = "m22" +from_ms = 22 +to_ms = 23 + +[[moment]] +id = "m23" +from_ms = 23 +to_ms = 24 + +[[moment]] +id = "m24" +from_ms = 24 +to_ms = 25 + +[[moment]] +id = "m25" +from_ms = 25 +to_ms = 26 + +[[moment]] +id = "m26" +from_ms = 26 +to_ms = 27 + +[[moment]] +id = "m27" +from_ms = 27 +to_ms = 28 + +[[moment]] +id = "m28" +from_ms = 28 +to_ms = 29 + +[[moment]] +id = "m29" +from_ms = 29 +to_ms = 30 + +[[moment]] +id = "m30" +from_ms = 30 +to_ms = 31 + +[[moment]] +id = "m31" +from_ms = 31 +to_ms = 32 + +[[moment]] +id = "m32" +from_ms = 32 +to_ms = 33 diff --git a/wai/pack-conformance/refusals/policy/too-many-rules.toml b/wai/pack-conformance/refusals/policy/too-many-rules.toml new file mode 100644 index 000000000..38ed85f67 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/too-many-rules.toml @@ -0,0 +1,114 @@ +# Refused: 33 rules. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[[rule]] +id = "r0" + +[[rule]] +id = "r1" + +[[rule]] +id = "r2" + +[[rule]] +id = "r3" + +[[rule]] +id = "r4" + +[[rule]] +id = "r5" + +[[rule]] +id = "r6" + +[[rule]] +id = "r7" + +[[rule]] +id = "r8" + +[[rule]] +id = "r9" + +[[rule]] +id = "r10" + +[[rule]] +id = "r11" + +[[rule]] +id = "r12" + +[[rule]] +id = "r13" + +[[rule]] +id = "r14" + +[[rule]] +id = "r15" + +[[rule]] +id = "r16" + +[[rule]] +id = "r17" + +[[rule]] +id = "r18" + +[[rule]] +id = "r19" + +[[rule]] +id = "r20" + +[[rule]] +id = "r21" + +[[rule]] +id = "r22" + +[[rule]] +id = "r23" + +[[rule]] +id = "r24" + +[[rule]] +id = "r25" + +[[rule]] +id = "r26" + +[[rule]] +id = "r27" + +[[rule]] +id = "r28" + +[[rule]] +id = "r29" + +[[rule]] +id = "r30" + +[[rule]] +id = "r31" + +[[rule]] +id = "r32" diff --git a/wai/pack-conformance/refusals/policy/type.toml b/wai/pack-conformance/refusals/policy/type.toml new file mode 100644 index 000000000..0cb2fd5a1 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/type.toml @@ -0,0 +1,16 @@ +# Refused: a string where a boolean belongs. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" +royalty_free_only = "yes" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/unknown-key-params.toml b/wai/pack-conformance/refusals/policy/unknown-key-params.toml new file mode 100644 index 000000000..47699927c --- /dev/null +++ b/wai/pack-conformance/refusals/policy/unknown-key-params.toml @@ -0,0 +1,16 @@ +# Refused: a parameter int_motion does not take. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" +params = { block = 16, qp = 3 } diff --git a/wai/pack-conformance/refusals/policy/unknown-key.toml b/wai/pack-conformance/refusals/policy/unknown-key.toml new file mode 100644 index 000000000..22b0b6742 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/unknown-key.toml @@ -0,0 +1,18 @@ +# Refused: a key the schema does not define. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" + +[segmentation] +keyframe_ms = 2000 diff --git a/wai/pack-conformance/refusals/policy/value-block.toml b/wai/pack-conformance/refusals/policy/value-block.toml new file mode 100644 index 000000000..8491f9504 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/value-block.toml @@ -0,0 +1,16 @@ +# Refused: an int_motion block size outside 4, 8, 16, 32. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" +params = { block = 12 } diff --git a/wai/pack-conformance/refusals/policy/value-media.toml b/wai/pack-conformance/refusals/policy/value-media.toml new file mode 100644 index 000000000..f08f55b5c --- /dev/null +++ b/wai/pack-conformance/refusals/policy/value-media.toml @@ -0,0 +1,15 @@ +# Refused: a media class the schema does not list. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "hologram" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/source/wav-format-8bit.wav b/wai/pack-conformance/refusals/source/wav-format-8bit.wav new file mode 100644 index 0000000000000000000000000000000000000000..3338c1fd942d776d2386f614171984a450271d9f GIT binary patch literal 48 rcmWIYbaP_>f^f$$*Ru_TcNqzDKAxReGt literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/refusals/source/wav-format-float.wav b/wai/pack-conformance/refusals/source/wav-format-float.wav new file mode 100644 index 0000000000000000000000000000000000000000..2d31a9b013e33abc31cc26ab16348501fd23d599 GIT binary patch literal 52 vcmWIYbaP_>f^f$$*Rf^f$$*R [--royalty-free-only] +//! the effective policy's canonical JSON, then its digest; a refused policy prints +//! its reason and exits 1 +//! wai-pack plan --policy --component [--source ] [--out ] +//! segment a video component with wai.pack.scenecut/1: one piece per line +//! (start, frames, flags, kind), then the plan digest +//! wai-pack fixture gen [-o ] +//! write a conformance fixture (Y4M); `fixture list` names them +//! wai-pack conformance regen|check +//! ``` + +#![deny(clippy::float_arithmetic)] + +use std::path::Path; +use std::process::ExitCode; +use wai_pack::policy::{Overrides, Policy}; +use wai_pack::{classify, conformance, fixture, hex, scenecut, source}; + +fn opt<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn usage() -> ExitCode { + eprintln!( + "usage:\n wai-pack policy [--royalty-free-only]\n \ + wai-pack plan --policy --component [--source ] [--out ]\n \ + wai-pack fixture gen [-o ] | fixture list\n \ + wai-pack conformance regen|check " + ); + ExitCode::from(2) +} + +fn load_policy(path: &str) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; + Policy::parse(&text).map_err(|e| e.to_string()) +} + +fn cmd_policy(args: &[String]) -> Result<(), String> { + let path = args.first().ok_or("policy: a policy file")?; + let o = Overrides { royalty_free_only: args.iter().any(|a| a == "--royalty-free-only").then_some(true) }; + let p = load_policy(path)?.effective(&o); + println!("{}", p.canonical_json()); + println!("{}", hex(&p.digest())); + Ok(()) +} + +fn cmd_plan(args: &[String]) -> Result<(), String> { + let path = opt(args, "--policy").ok_or("plan: --policy")?; + let id = opt(args, "--component").ok_or("plan: --component")?; + let p = load_policy(path)?; + let c = p.component(id).ok_or_else(|| format!("plan: the policy has no component {id}"))?; + let src = opt(args, "--source") + .map(str::to_string) + .or_else(|| c.source.as_ref().map(|s| Path::new(path).parent().unwrap_or(Path::new(".")).join(s).to_string_lossy().into_owned())) + .ok_or("plan: the component names no source; pass --source")?; + let file = std::fs::File::open(&src).map_err(|e| format!("{src}: {e}"))?; + let mut r = source::y4m::Y4mReader::new(std::io::BufReader::new(file)).map_err(|e| e.to_string())?; + if let Some((n, d)) = r.header.rate + && (n, d) != (c.timebase.num, c.timebase.den) + { + return Err(format!("plan: the source's frame rate {n}:{d} is not the component's timebase")); + } + let mut s = scenecut::Segmenter::new(scenecut::SegParams::from_policy(&p.segmentation, c.timebase), r.header.w, r.header.h); + let mut pieces = Vec::new(); + while let Some(f) = r.next_frame() { + pieces.extend(s.push(&f.map_err(|e| e.to_string())?.rgb)); + } + pieces.extend(s.finish()); + let mut text = String::new(); + for x in &pieces { + let kind = classify::classify(&x.stats, &p.classify).label(); + text.push_str(&format!("{} {} {} {}\n", x.start, x.len, x.flags, kind)); + } + let starts: Vec = pieces.iter().map(|x| x.start).collect(); + match opt(args, "--out") { + Some(o) => std::fs::write(o, &text).map_err(|e| format!("{o}: {e}"))?, + None => print!("{text}"), + } + println!("{}", hex(&scenecut::plan_digest(&starts))); + Ok(()) +} + +fn cmd_fixture(args: &[String]) -> Result<(), String> { + match args.first().map(String::as_str) { + Some("list") => { + fixture::NAMES.iter().for_each(|n| println!("{n}")); + Ok(()) + } + Some("gen") => { + let name = args.get(1).ok_or("fixture gen: a name")?; + let b = fixture::generate(name).ok_or_else(|| format!("fixture gen: no fixture {name}"))?; + match opt(args, "-o") { + Some(o) => std::fs::write(o, b).map_err(|e| format!("{o}: {e}")), + None => { + use std::io::Write as _; + std::io::stdout().write_all(&b).map_err(|e| e.to_string()) + } + } + } + _ => Err("fixture: gen | list".into()), + } +} + +fn cmd_conformance(args: &[String]) -> Result<(), String> { + let dir = Path::new(args.get(1).ok_or("conformance: a corpus directory")?); + match args.first().map(String::as_str) { + Some("regen") => conformance::regen(dir).map_err(|e| e.to_string()), + Some("check") => { + let m = conformance::check(dir).map_err(|e| e.to_string())?; + for x in &m { + eprintln!("MISMATCH {x}"); + } + if m.is_empty() { + println!("pack-conformance: every vector holds"); + Ok(()) + } else { + Err(format!("{} mismatches", m.len())) + } + } + _ => Err("conformance: regen|check ".into()), + } +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let Some(cmd) = args.first() else { + return usage(); + }; + let rest = &args[1..]; + let r = match cmd.as_str() { + "policy" => cmd_policy(rest), + "plan" => cmd_plan(rest), + "fixture" => cmd_fixture(rest), + "conformance" => cmd_conformance(rest), + _ => return usage(), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("wai-pack: {e}"); + ExitCode::from(1) + } + } +} diff --git a/wai/wai-pack/src/classify.rs b/wai/wai-pack/src/classify.rs new file mode 100644 index 000000000..04a06eb3b --- /dev/null +++ b/wai/wai-pack/src/classify.rs @@ -0,0 +1,79 @@ +//! Piece classification ([packaging] §4.6), from the statistics the segmenter +//! gathers. Precedence: static, then synthetic, then high motion, then natural. +//! +//! - `mad_milli = abs_diff · 1000 / compared`, 0 for a one-frame piece: the mean +//! absolute luma change between consecutive frames, in thousandths. +//! - `changed_permille = changed · 1000 / compared`, 0 for a one-frame piece. +//! - static: `mad_milli ≤ static_mad_milli` and `changed_permille ≤ static_permille`; +//! - synthetic: the first frame's histogram has at most `synthetic_bins` non-empty bins; +//! - high motion: `mad_milli ≥ motion_high_milli`. +//! +//! [packaging]: ../../../extensions/packaging.md + +use crate::policy::ClassifyParams; +use crate::scenecut::PieceStats; + +/// A piece's kind. The discriminant is the stage index's `kind` code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PieceKind { + Natural = 0, + Static = 1, + Synthetic = 2, + MotionHigh = 3, +} + +impl PieceKind { + /// The policy's spelling (`rule.when.piece_kind`). + pub fn label(self) -> &'static str { + match self { + PieceKind::Natural => "natural", + PieceKind::Static => "static", + PieceKind::Synthetic => "synthetic", + PieceKind::MotionHigh => "motion_high", + } + } +} + +/// `mad_milli` of a piece. +pub fn mad_milli(s: &PieceStats) -> u64 { + (s.abs_diff * 1000).checked_div(s.compared).unwrap_or(0) +} + +/// `changed_permille` of a piece. +pub fn changed_permille(s: &PieceStats) -> u64 { + (s.changed * 1000).checked_div(s.compared).unwrap_or(0) +} + +/// The kind of a piece. +pub fn classify(s: &PieceStats, p: &ClassifyParams) -> PieceKind { + let mad = mad_milli(s); + if mad <= p.static_mad_milli && changed_permille(s) <= p.static_permille { + PieceKind::Static + } else if s.occupancy as u64 <= p.synthetic_bins { + PieceKind::Synthetic + } else if mad >= p.motion_high_milli { + PieceKind::MotionHigh + } else { + PieceKind::Natural + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const P: ClassifyParams = ClassifyParams { static_permille: 5, static_mad_milli: 300, synthetic_bins: 12, motion_high_milli: 9000 }; + + #[test] + fn precedence_and_thresholds() { + let st = |abs_diff, changed, occupancy| PieceStats { abs_diff, changed, compared: 1000, occupancy }; + assert_eq!(classify(&st(300, 5, 40), &P), PieceKind::Static); + assert_eq!(classify(&st(301, 5, 40), &P), PieceKind::Natural); + assert_eq!(classify(&st(300, 6, 12), &P), PieceKind::Synthetic); + assert_eq!(classify(&st(9000, 900, 12), &P), PieceKind::Synthetic); + assert_eq!(classify(&st(9000, 900, 13), &P), PieceKind::MotionHigh); + assert_eq!(classify(&st(8999, 900, 13), &P), PieceKind::Natural); + // A one-frame piece compares nothing: static. + assert_eq!(classify(&PieceStats { occupancy: 40, ..PieceStats::default() }, &P), PieceKind::Static); + } +} diff --git a/wai/wai-pack/src/conformance.rs b/wai/wai-pack/src/conformance.rs new file mode 100644 index 000000000..73ba1b06a --- /dev/null +++ b/wai/wai-pack/src/conformance.rs @@ -0,0 +1,328 @@ +//! The packaging conformance corpus (`wai/pack-conformance/`): what this crate +//! computes from its inputs, written by [`regen`] and checked by [`check`]. An +//! independent verifier (`pack-conformance/pack_verify.py`) recomputes the same +//! values from the extension text. +//! +//! - `policy/`: policies and `expected.json` (each one's canonical JSON and digest, +//! with any overrides; and the pairs `eq--a.toml`, `eq--b.toml`, two +//! spellings of one policy, which must have one digest). +//! - `refusals/`: policies and sources a packager refuses, and `expected.json` (each +//! one's reason). The binary sources are written by [`regen`] from +//! [`source_refusals`]. +//! - `conversions/` and `conversions.json`: the unit conversions (milliseconds to a +//! timebase, alignment to the lead) and the Y4M-to-RGB conversion, by the SHA-256 of +//! each RGB8 frame. +//! - `scenecut/`: a policy and `expected.json`: per fixture, its SHA-256, every frame's +//! score, and the pieces with their flags, statistics and kinds, and the plan digest. + +use crate::classify::{changed_permille, classify, mad_milli}; +use crate::policy::{Overrides, Policy, Timebase}; +use crate::scenecut::{plan_digest, segment, SegParams}; +use crate::source::{wav::WavReader, y4m}; +use crate::{fixture, hex, sha256_hex}; +use serde_json::{json, Value}; +use std::path::Path; + +/// One difference between the corpus and what this crate computes. +pub type Mismatch = String; + +fn read(p: &Path) -> std::io::Result> { + std::fs::read(p).map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", p.display()))) +} + +fn read_json(p: &Path) -> std::io::Result { + serde_json::from_slice(&read(p)?).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{}: {e}", p.display()))) +} + +fn write_json(p: &Path, v: &Value) -> std::io::Result<()> { + let mut s = serde_json::to_string_pretty(v).expect("JSON"); + s.push('\n'); + std::fs::write(p, s) +} + +/// The `.toml` files of `dir`, sorted bytewise. +fn tomls(dir: &Path) -> std::io::Result> { + let mut v: Vec = std::fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".toml")) + .collect(); + v.sort(); + Ok(v) +} + +// ─────────────────────────────── policy ─────────────────────────────── + +/// The overrides a policy case applies: `example.toml` is also digested with +/// `royalty_free_only` forced off. +fn policy_cases(names: &[String]) -> Vec<(String, Overrides)> { + let mut v: Vec<(String, Overrides)> = names.iter().map(|n| (n.clone(), Overrides::default())).collect(); + if names.iter().any(|n| n == "example.toml") { + v.push(("example.toml".into(), Overrides { royalty_free_only: Some(false) })); + } + v +} + +fn policy_expected(dir: &Path) -> std::io::Result { + let names = tomls(dir)?; + let mut cases = Vec::new(); + for (name, o) in policy_cases(&names) { + let text = String::from_utf8_lossy(&read(&dir.join(&name))?).into_owned(); + let p = Policy::parse(&text) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{name}: {e}")))? + .effective(&o); + let mut c = json!({"file": name, "canonical": p.canonical_json(), "digest": hex(&p.digest())}); + if let Some(b) = o.royalty_free_only { + c["overrides"] = json!({"royalty_free_only": b}); + } + cases.push(c); + } + let equal: Vec = names + .iter() + .filter_map(|n| n.strip_suffix("-a.toml").filter(|s| s.starts_with("eq-"))) + .map(|s| json!([format!("{s}-a.toml"), format!("{s}-b.toml")])) + .collect(); + Ok(json!({"cases": cases, "equal": equal})) +} + +// ─────────────────────────────── refusals ─────────────────────────────── + +/// The source refusal cases, written by [`regen`]: a file name and its bytes. Each +/// is refused with the reason its name carries. +pub fn source_refusals() -> Vec<(&'static str, Vec)> { + let frame = [16u8; 6]; + let y4m = |head: &[u8], body: &[u8]| [head, body].concat(); + let wav = |tag: u16, channels: u16, bits: u16, data: &[u8]| { + let mut b = b"RIFF\0\0\0\0WAVEfmt \x10\0\0\0".to_vec(); + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&channels.to_le_bytes()); + b.extend_from_slice(&8000u32.to_le_bytes()); + b.extend_from_slice(&(8000 * channels as u32 * bits as u32 / 8).to_le_bytes()); + b.extend_from_slice(&(channels * bits / 8).to_le_bytes()); + b.extend_from_slice(&bits.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(data); + b + }; + vec![ + ("y4m-header.y4m", y4m(b"YUV4MPEG W2 H2 F25:1\n", &[])), + ("y4m-interlaced.y4m", y4m(b"YUV4MPEG2 W2 H2 F25:1 It C420jpeg\nFRAME\n", &frame)), + ("y4m-interlaced-frame.y4m", y4m(b"YUV4MPEG2 W2 H2 F25:1 C420jpeg\nFRAME Ib\n", &frame)), + ("y4m-colorspace.y4m", y4m(b"YUV4MPEG2 W2 H2 F25:1 C422\nFRAME\n", &[16; 8])), + ("y4m-size-change.y4m", y4m(b"YUV4MPEG2 W2 H2 F25:1 C420jpeg\nFRAME\n", &[&frame[..], b"FRAME W4\n", &frame].concat())), + ("y4m-truncated.y4m", y4m(b"YUV4MPEG2 W2 H2 F25:1 C420jpeg\nFRAME\n", &frame[..5])), + ("wav-format-float.wav", wav(3, 1, 32, &[0; 8])), + ("wav-format-8bit.wav", wav(1, 1, 8, &[0; 4])), + ("wav-truncated.wav", { + let mut w = wav(1, 2, 16, &[0; 8]); + w.truncate(w.len() - 2); + w + }), + ] +} + +/// The reason a source file is refused with, or `ok`. +pub fn source_verdict(name: &str, bytes: &[u8]) -> &'static str { + if name.ends_with(".y4m") { + y4m::read_all(bytes).map(|_| "ok").unwrap_or_else(|e| e.code()) + } else { + let read = || -> Result<(), crate::source::SourceError> { + let mut r = WavReader::new(bytes)?; + let n = r.remaining(); + r.next_block(n)?; + Ok(()) + }; + read().map(|_| "ok").unwrap_or_else(|e| e.code()) + } +} + +fn policy_verdict(text: &str) -> &'static str { + Policy::parse(text).map(|_| "ok").unwrap_or_else(|e| e.code()) +} + +fn refusals_expected(dir: &Path) -> std::io::Result { + let mut cases = Vec::new(); + for name in tomls(&dir.join("policy"))? { + let text = String::from_utf8_lossy(&read(&dir.join("policy").join(&name))?).into_owned(); + cases.push(json!({"file": format!("policy/{name}"), "reason": policy_verdict(&text)})); + } + for (name, bytes) in source_refusals() { + cases.push(json!({"file": format!("source/{name}"), "reason": source_verdict(name, &bytes)})); + } + Ok(json!({"cases": cases})) +} + +// ─────────────────────────────── conversions ─────────────────────────────── + +const MS_CASES: &[(u64, u32, u32)] = &[ + (1000, 25, 1), + (40, 25, 1), + (39, 25, 1), + (0, 25, 1), + (1000, 30000, 1001), + (2000, 30000, 1001), + (1, 48000, 1), + (4000, 90000, 1), + (9_007_199_254_740_991, 4_294_967_295, 1), +]; + +/// A timebase as `(num, den)`. +type Rate = (u32, u32); + +const ALIGN_CASES: &[(u64, Rate, Rate)] = &[ + (50, (25, 1), (48000, 1)), + (3, (25, 1), (30000, 1001)), + (29, (30000, 1001), (25, 1)), + (48000, (48000, 1), (25, 1)), + (47999, (48000, 1), (25, 1)), + (u64::MAX, (1, 4_294_967_295), (4_294_967_295, 1)), +]; + +/// The Y4M conversion cases, written by [`regen`]: 4:2:0 with odd dimensions, and 4:4:4, +/// whose samples sweep the range so that every clip of the conversion is reached. +pub fn conversion_sources() -> Vec<(&'static str, Vec)> { + let planes = |w: usize, h: usize, cw: usize, ch: usize, frames: usize| { + let mut b = Vec::new(); + for t in 0..frames { + b.extend_from_slice(b"FRAME\n"); + for i in 0..w * h { + b.push(((i * 37 + t * 101) % 256) as u8); + } + for i in 0..cw * ch { + b.push(((i * 59 + t * 7 + 13) % 256) as u8); + } + for i in 0..cw * ch { + b.push(((i * 83 + t * 29 + 101) % 256) as u8); + } + } + b + }; + vec![ + ("bt709-420.y4m", [&b"YUV4MPEG2 W7 H5 F25:1 Ip C420jpeg\n"[..], &planes(7, 5, 4, 3, 3)].concat()), + ("bt709-444.y4m", [&b"YUV4MPEG2 W6 H4 F25:1 C444\n"[..], &planes(6, 4, 6, 4, 2)].concat()), + ] +} + +fn conversions_expected() -> Value { + let ms: Vec = MS_CASES + .iter() + .map(|&(ms, num, den)| json!({"ms": ms, "num": num, "den": den, "units": Timebase { num, den }.units_from_ms(ms)})) + .collect(); + let align: Vec = ALIGN_CASES + .iter() + .map(|&(start, (ln, ld), (cn, cd))| { + let units = Timebase { num: cn, den: cd }.align_from(start, Timebase { num: ln, den: ld }); + json!({"start": start, "lead": [ln, ld], "component": [cn, cd], "units": units}) + }) + .collect(); + let rgb: Vec = conversion_sources() + .into_iter() + .map(|(name, b)| { + let (_, frames) = y4m::read_all(&b).expect("a conversion source reads"); + let shas: Vec = frames.iter().map(|f| sha256_hex(&f.rgb)).collect(); + json!({"file": format!("conversions/{name}"), "rgb_sha256": shas}) + }) + .collect(); + json!({"ms_to_units": ms, "align": align, "y4m_rgb": rgb}) +} + +// ─────────────────────────────── scene cut ─────────────────────────────── + +/// The scene-cut vectors of every fixture under the policy at `policy`, component `v`. +pub fn scenecut_expected(policy_text: &str) -> Result { + let p = Policy::parse(policy_text).map_err(|e| e.to_string())?; + let c = p.component("v").ok_or("the scene-cut policy has no component v")?; + let sp = SegParams::from_policy(&p.segmentation, c.timebase); + let mut out = Vec::new(); + for name in fixture::NAMES { + let bytes = fixture::generate(name).expect("a listed fixture"); + let (hd, frames) = y4m::read_all(&bytes).map_err(|e| e.to_string())?; + let (pieces, scores) = segment(sp, hd.w, hd.h, frames.iter().map(|f| &f.rgb)); + let starts: Vec = pieces.iter().map(|x| x.start).collect(); + let pieces: Vec = pieces + .iter() + .map(|x| { + json!({ + "start": x.start, "len": x.len, "flags": x.flags, + "kind": classify(&x.stats, &p.classify).label(), + "mad_milli": mad_milli(&x.stats), "changed_permille": changed_permille(&x.stats), + "occupancy": x.stats.occupancy, + }) + }) + .collect(); + out.push(json!({ + "name": name, "sha256": sha256_hex(&bytes), "frames": frames.len(), + "scores": scores, "pieces": pieces, "plan_digest": hex(&plan_digest(&starts)), + })); + } + Ok(json!({"policy": "policy.toml", "component": "v", "fixtures": out})) +} + +// ─────────────────────────────── regen and check ─────────────────────────────── + +/// Write every expected file, and the generated inputs, under `dir`. +pub fn regen(dir: &Path) -> std::io::Result<()> { + write_json(&dir.join("policy/expected.json"), &policy_expected(&dir.join("policy"))?)?; + std::fs::create_dir_all(dir.join("refusals/source"))?; + for (name, bytes) in source_refusals() { + std::fs::write(dir.join("refusals/source").join(name), bytes)?; + } + write_json(&dir.join("refusals/expected.json"), &refusals_expected(&dir.join("refusals"))?)?; + std::fs::create_dir_all(dir.join("conversions"))?; + for (name, bytes) in conversion_sources() { + std::fs::write(dir.join("conversions").join(name), bytes)?; + } + write_json(&dir.join("conversions.json"), &conversions_expected())?; + let text = String::from_utf8_lossy(&read(&dir.join("scenecut/policy.toml"))?).into_owned(); + let sc = scenecut_expected(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + write_json(&dir.join("scenecut/expected.json"), &sc)?; + Ok(()) +} + +fn same(what: &str, want: &Value, got: &Value, out: &mut Vec) { + if want != got { + out.push(format!("{what}: the corpus and this implementation differ")); + } +} + +/// Recompute the corpus under `dir` and list every difference; empty means it holds. +/// Generated inputs are compared byte for byte with what [`regen`] would write. +pub fn check(dir: &Path) -> std::io::Result> { + let mut out = Vec::new(); + same("policy/expected.json", &read_json(&dir.join("policy/expected.json"))?, &policy_expected(&dir.join("policy"))?, &mut out); + let pol = read_json(&dir.join("policy/expected.json"))?; + let digest_of = |f: &str| { + pol["cases"].as_array().and_then(|c| c.iter().find(|x| x["file"] == f && x.get("overrides").is_none())).map(|x| x["digest"].clone()) + }; + for pair in pol["equal"].as_array().into_iter().flatten() { + let (a, b) = (pair[0].as_str().unwrap_or(""), pair[1].as_str().unwrap_or("")); + if digest_of(a).is_none() || digest_of(a) != digest_of(b) { + out.push(format!("policy: {a} and {b} spell one policy but their digests differ")); + } + } + for (name, bytes) in source_refusals() { + if read(&dir.join("refusals/source").join(name))? != bytes { + out.push(format!("refusals/source/{name}: not the bytes the corpus generator writes")); + } + } + let refusals = read_json(&dir.join("refusals/expected.json"))?; + same("refusals/expected.json", &refusals, &refusals_expected(&dir.join("refusals"))?, &mut out); + for c in refusals["cases"].as_array().into_iter().flatten() { + if c["reason"] == "ok" { + out.push(format!("refusals: {} is not refused", c["file"])); + } + } + for (name, bytes) in conversion_sources() { + if read(&dir.join("conversions").join(name))? != bytes { + out.push(format!("conversions/{name}: not the bytes the corpus generator writes")); + } + } + same("conversions.json", &read_json(&dir.join("conversions.json"))?, &conversions_expected(), &mut out); + let text = String::from_utf8_lossy(&read(&dir.join("scenecut/policy.toml"))?).into_owned(); + match scenecut_expected(&text) { + Ok(sc) => same("scenecut/expected.json", &read_json(&dir.join("scenecut/expected.json"))?, &sc, &mut out), + Err(e) => out.push(format!("scenecut/policy.toml: {e}")), + } + Ok(out) +} diff --git a/wai/wai-pack/src/fixture.rs b/wai/wai-pack/src/fixture.rs new file mode 100644 index 000000000..a74514f2b --- /dev/null +++ b/wai/wai-pack/src/fixture.rs @@ -0,0 +1,161 @@ +//! Synthetic sources for the conformance corpus, generated rather than committed. +//! +//! Each fixture is a Y4M stream (`C420jpeg`, progressive, 25 frames per second) whose +//! every byte is an integer function of the frame index and the pixel position, so an +//! independent implementation regenerates it byte for byte; the corpus pins each +//! fixture's SHA-256. A fixture is a list of spans; a span shows one shot, or fades from +//! one shot to another, for a number of frames. Shots read the global frame index `t`, +//! so a shot that resumes after an interruption continues its motion. +//! +//! - `ramp`: `Y = lo + v·(hi − lo) / (period − 1)`, `v = (x·dx + y·dy + t·speed) mod +//! period`; with `jitter` 1, `Y` moves by `noise mod 3 − 1`, clamped to 16..=235. +//! - `blocks`: `Y = levels[((x + t·speed) / bw + y / bh) mod 3]`. +//! - `noise`: `Y = lo + noise mod (hi − lo + 1)`. +//! - A fade of `n` frames from shot `a` to shot `b`, at its `i`-th frame (from 0), is +//! `(a·(n − i − 1) + b·(i + 1)) / n` per sample, chroma included. +//! +//! `noise(seed, x, y, t) = mix(seed ^ mix(x ^ mix(y ^ mix(t))))`, where `mix` is the +//! 32-bit finaliser `v ^= v >> 16; v *= 0x7feb352d; v ^= v >> 15; v *= 0x846ca68b; +//! v ^= v >> 16` (wrapping). Chroma is constant per shot. + +/// The fixtures this module generates. +pub const NAMES: &[&str] = &["cuts-01", "kinds-01"]; + +fn mix(mut v: u32) -> u32 { + v ^= v >> 16; + v = v.wrapping_mul(0x7feb_352d); + v ^= v >> 15; + v = v.wrapping_mul(0x846c_a68b); + v ^= v >> 16; + v +} + +/// The fixtures' noise. +pub fn noise(seed: u32, x: u32, y: u32, t: u32) -> u32 { + mix(seed ^ mix(x ^ mix(y ^ mix(t)))) +} + +#[derive(Debug, Clone, Copy)] +enum Shot { + Ramp { lo: u32, hi: u32, dx: u32, dy: u32, speed: u32, period: u32, jitter: bool, seed: u32, cb: u8, cr: u8 }, + Blocks { levels: [u8; 3], bw: u32, bh: u32, speed: u32, cb: u8, cr: u8 }, + Noise { lo: u32, hi: u32, seed: u32, cb: u8, cr: u8 }, +} + +impl Shot { + fn y(&self, x: u32, y: u32, t: u32) -> u32 { + match *self { + Shot::Ramp { lo, hi, dx, dy, speed, period, jitter, seed, .. } => { + let v = (x * dx + y * dy + t * speed) % period; + let base = lo + v * (hi - lo) / (period - 1); + if jitter { (base + noise(seed, x, y, t) % 3).saturating_sub(1).clamp(16, 235) } else { base } + } + Shot::Blocks { levels, bw, bh, speed, .. } => levels[(((x + t * speed) / bw + y / bh) % 3) as usize] as u32, + Shot::Noise { lo, hi, seed, .. } => lo + noise(seed, x, y, t) % (hi - lo + 1), + } + } + fn chroma(&self) -> (u32, u32) { + match *self { + Shot::Ramp { cb, cr, .. } | Shot::Blocks { cb, cr, .. } | Shot::Noise { cb, cr, .. } => (cb as u32, cr as u32), + } + } +} + +struct Span { + frames: u32, + shot: Shot, + fade_to: Option, +} + +#[allow(clippy::too_many_arguments)] +const fn ramp(lo: u32, hi: u32, dx: u32, dy: u32, speed: u32, jitter: bool, seed: u32, cb: u8, cr: u8) -> Shot { + Shot::Ramp { lo, hi, dx, dy, speed, period: 32, jitter, seed, cb, cr } +} + +fn spans(name: &str) -> Option<(u32, u32, Vec)> { + let s = |frames, shot| Span { frames, shot, fade_to: None }; + Some(match name { + // Hard cuts; a flash of exactly 6 frames (the default lookahead) inside shot B; + // a 40-frame fade from C to D; and two shots cut against each other, E and F. + "cuts-01" => { + let a = ramp(20, 100, 1, 1, 1, true, 1, 110, 140); + let b = ramp(120, 220, 2, 1, 1, true, 2, 150, 120); + let flash = Shot::Noise { lo: 228, hi: 235, seed: 3, cb: 128, cr: 128 }; + let c = ramp(30, 90, 1, 2, 1, true, 4, 120, 120); + let d = ramp(150, 230, 1, 0, 2, true, 5, 136, 136); + let e = ramp(40, 120, 0, 1, 1, true, 6, 100, 150); + let f = ramp(140, 200, 1, 0, 1, true, 7, 160, 110); + (32, 18, vec![ + s(60, a), s(30, b), s(6, flash), s(44, b), s(20, c), + Span { frames: 40, shot: c, fade_to: Some(d) }, + s(30, d), s(30, e), s(30, f), s(30, e), s(30, f), + ]) + } + // One shot of each kind, cut against each other, then a static shot longer than + // the longest piece. + "kinds-01" => (32, 18, vec![ + s(40, ramp(20, 200, 1, 1, 0, false, 0, 128, 128)), + s(40, Shot::Blocks { levels: [40, 120, 200], bw: 8, bh: 6, speed: 1, cb: 128, cr: 128 }), + s(40, Shot::Noise { lo: 16, hi: 235, seed: 7, cb: 128, cr: 128 }), + s(40, ramp(16, 80, 1, 1, 1, true, 8, 128, 128)), + s(130, ramp(60, 180, 2, 1, 0, false, 0, 140, 116)), + ]), + _ => return None, + }) +} + +/// The fixture `name` as Y4M bytes, or `None` for a name [`NAMES`] does not hold. +pub fn generate(name: &str) -> Option> { + let (w, h, spans) = spans(name)?; + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let mut out = format!("YUV4MPEG2 W{w} H{h} F25:1 Ip A1:1 C420jpeg\n").into_bytes(); + let mut t = 0u32; + for span in &spans { + for i in 0..span.frames { + out.extend_from_slice(b"FRAME\n"); + let blend = |a: u32, b: u32| (a * (span.frames - i - 1) + b * (i + 1)) / span.frames; + for y in 0..h { + for x in 0..w { + let v = span.shot.y(x, y, t); + let v = span.fade_to.map_or(v, |to| blend(v, to.y(x, y, t))); + out.push(v as u8); + } + } + let (cb, cr) = span.shot.chroma(); + let (cb, cr) = span.fade_to.map_or((cb, cr), |to| { + let (tb, tr) = to.chroma(); + (blend(cb, tb), blend(cr, tr)) + }); + out.extend(std::iter::repeat_n(cb as u8, (cw * ch) as usize)); + out.extend(std::iter::repeat_n(cr as u8, (cw * ch) as usize)); + t += 1; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Known answers computed independently (Python, arbitrary-precision integers + /// masked to 32 bits). + #[test] + fn mix_and_noise_known_answers() { + assert_eq!(mix(0), 0); + assert_eq!(mix(1), 0x6889_90c0); + assert_eq!(mix(0xdead_beef), 0xe628_c683); + assert_eq!(noise(3, 5, 7, 11), 3_929_262_924); + } + + #[test] + fn every_fixture_is_a_y4m_stream() { + for n in NAMES { + let b = generate(n).unwrap(); + let (hd, frames) = crate::source::y4m::read_all(&b).unwrap(); + assert_eq!((hd.w, hd.h, hd.rate), (32, 18, Some((25, 1))), "{n}"); + assert!(frames.len() > 200, "{n}"); + } + assert!(generate("nope").is_none()); + } +} diff --git a/wai/wai-pack/src/lib.rs b/wai/wai-pack/src/lib.rs new file mode 100644 index 000000000..5405b6f2f --- /dev/null +++ b/wai/wai-pack/src/lib.rs @@ -0,0 +1,34 @@ +//! wai-pack: the reference packager of WAI's packaging extension +//! ([`extensions/packaging.md`](../../../extensions/packaging.md)). +//! +//! What this revision builds: the policy and its digest ([`policy`]), the source +//! readers ([`source`]), `wai.pack.scenecut/1` ([`scenecut`]), piece classification +//! ([`classify`]), the synthetic fixtures ([`fixture`]), and the conformance corpus's +//! regeneration and check ([`conformance`]). +//! +//! No float decides anything here: every computation is integer, and float arithmetic +//! is denied for the whole crate. + +#![deny(clippy::float_arithmetic)] + +pub mod classify; +pub mod conformance; +pub mod fixture; +pub mod policy; +pub mod scenecut; +pub mod source; + +/// Lowercase hex of `b`. +pub fn hex(b: &[u8]) -> String { + use std::fmt::Write as _; + b.iter().fold(String::with_capacity(2 * b.len()), |mut s, x| { + let _ = write!(s, "{x:02x}"); + s + }) +} + +/// SHA-256 of `b`, as lowercase hex. +pub fn sha256_hex(b: &[u8]) -> String { + use sha2::Digest as _; + hex(&sha2::Sha256::digest(b)) +} diff --git a/wai/wai-pack/src/policy.rs b/wai/wai-pack/src/policy.rs new file mode 100644 index 000000000..9df91dfcb --- /dev/null +++ b/wai/wai-pack/src/policy.rs @@ -0,0 +1,1195 @@ +//! The packaging policy, schema `wai.pack.policy/1` ([packaging] §3). +//! +//! A package is made under one policy, a TOML 1.0 document. [`Policy::parse`] reads it +//! in three stages, and the first fault it meets is the refusal: +//! +//! 1. **Value kinds.** Anything canonical JSON cannot carry exactly is refused before +//! any key is read: a float, a date or time, a negative integer, an integer past +//! `2^53 − 1`. The walk visits members in byte order of their keys and arrays in +//! order. +//! 2. **Schema.** In each table, a key the schema does not define is refused first (in +//! byte order); then each member in schema order: a missing required member, a member +//! of the wrong type, a value outside its range. +//! 3. **Constraints** across members, in the order [`PolicyReason`] lists them from +//! `TooManyComponents` on. +//! +//! The **effective policy** is the parsed policy with every default written in and the +//! overrides applied ([`Policy::effective`]). Its canonical JSON +//! ([`Policy::canonical_json`]) is RFC 8785 over the JSON form [`Policy::to_value`] +//! states, and its digest is `BLAKE3("wai:pack-policy\x01" ‖ canonical JSON)`. Two +//! spellings of one policy (key order, inline or standard tables, dotted keys, +//! comments, an omitted default or the default written out) therefore have one digest. +//! +//! [packaging]: ../../../extensions/packaging.md + +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// The schema string a policy names. +pub const SCHEMA: &str = "wai.pack.policy/1"; +/// The domain of the policy digest. +pub const DOMAIN_POLICY: &[u8] = b"wai:pack-policy\x01"; +/// The scene-cut algorithm of [packaging] §4.3, the one `segmentation.scene_cut` names. +/// +/// [packaging]: ../../../extensions/packaging.md +pub const SCENECUT_V1: &str = "wai.pack.scenecut/1"; +/// The largest integer a policy may hold: canonical JSON's exact-integer bound. +pub const MAX_INTEGER: u64 = wai::jcs::MAX_INTEGER; + +/// The most components, rungs, classes, rules and moments a policy may hold, and the +/// most distinct deadline values. +pub const MAX_COMPONENTS: usize = 8; +pub const MAX_RUNGS: usize = 64; +pub const MAX_CLASSES: usize = 32; +pub const MAX_RULES: usize = 32; +pub const MAX_MOMENTS: usize = 32; +pub const MAX_DEADLINES: usize = 4; + +/// Why a policy was refused. [`PolicyReason::code`] is the name the conformance corpus +/// and [packaging] §12 use. +/// +/// [packaging]: ../../../extensions/packaging.md +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PolicyReason { + /// Not a TOML 1.0 document (a syntax error, a key defined twice). + Toml, + /// A float anywhere in the document. + Float, + /// A date, time or date-time anywhere in the document. + Datetime, + /// A negative integer anywhere in the document. + Negative, + /// An integer past `2^53 − 1` anywhere in the document. + IntegerRange, + /// A key the schema does not define. + UnknownKey, + /// A required member is absent. + MissingKey, + /// A member of the wrong type. + Type, + /// A value outside its member's range or vocabulary. + Value, + /// `schema` is not `wai.pack.policy/1`. + Schema, + /// More than 8 components, or none. + TooManyComponents, + /// More than 64 ladder rungs. + TooManyRungs, + /// More than 32 classes. + TooManyClasses, + /// More than 32 rules. + TooManyRules, + /// More than 32 moments. + TooManyMoments, + /// Two components, rungs, classes, moments or rules share an id. + DuplicateId, + /// `lead` names no component. + Lead, + /// `segmentation.closed` is false: every piece must be closed (§4.1). + OpenGop, + /// Not `1 ≤ min_ms ≤ target_ms ≤ max_ms`. + PieceBounds, + /// A rung names a component the policy does not define. + LadderComponent, + /// A refinement without `refines`, or `refines` on another role. + RefinesRole, + /// `refines` names no rung. + DanglingRefines, + /// A `refines` chain returns to a rung it passed. + RefinesCycle, + /// A `refines` chain does not end at a base of the refinement's own component. + RefinesBase, + /// Two refinements refine one rung. + RefinesBranch, + /// A component has no base rung. + NoBase, + /// A class's or a rule's `max_layer` that no rung reaches. + ClassLayer, + /// A rule's `then.ladder` names an unknown rung, rungs of two components, or no base. + RuleLadder, + /// `live.layer_lag_ms` is not one entry per layer, starting at 0, non-decreasing. + LayerLag, + /// More than 4 distinct deadline values. + TooManyDeadlines, + /// A deadline that a refinement layer cannot meet: `deadline_ms − layer_lag_ms(k) < 1`. + DeadlineUnreachable, +} + +impl PolicyReason { + /// The refusal's name. + pub fn code(self) -> &'static str { + use PolicyReason::*; + match self { + Toml => "policy-toml", + Float => "policy-float", + Datetime => "policy-datetime", + Negative => "policy-negative", + IntegerRange => "policy-integer-range", + UnknownKey => "policy-unknown-key", + MissingKey => "policy-missing-key", + Type => "policy-type", + Value => "policy-value", + Schema => "policy-schema", + TooManyComponents => "policy-too-many-components", + TooManyRungs => "policy-too-many-rungs", + TooManyClasses => "policy-too-many-classes", + TooManyRules => "policy-too-many-rules", + TooManyMoments => "policy-too-many-moments", + DuplicateId => "policy-duplicate-id", + Lead => "policy-lead", + OpenGop => "policy-open-gop", + PieceBounds => "policy-piece-bounds", + LadderComponent => "policy-ladder-component", + RefinesRole => "policy-refines-role", + DanglingRefines => "policy-dangling-refines", + RefinesCycle => "policy-refines-cycle", + RefinesBase => "policy-refines-base", + RefinesBranch => "policy-refines-branch", + NoBase => "policy-no-base", + ClassLayer => "policy-class-layer", + RuleLadder => "policy-rule-ladder", + LayerLag => "policy-layer-lag", + TooManyDeadlines => "policy-too-many-deadlines", + DeadlineUnreachable => "policy-deadline-unreachable", + } + } +} + +/// A refusal: the reason and where in the document it was found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyError { + pub reason: PolicyReason, + pub at: String, +} + +impl PolicyError { + fn new(reason: PolicyReason, at: impl Into) -> Self { + PolicyError { reason, at: at.into() } + } + /// The refusal's name. + pub fn code(&self) -> &'static str { + self.reason.code() + } +} + +impl std::fmt::Display for PolicyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} at {}", self.reason.code(), self.at) + } +} + +impl std::error::Error for PolicyError {} + +type R = Result; + +/// The media class of a component. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Media { + Video, + Image, + Audio, + World, + Feed, + Avatar, + Film, +} + +impl Media { + const ALL: [Media; 7] = [Media::Video, Media::Image, Media::Audio, Media::World, Media::Feed, Media::Avatar, Media::Film]; + /// The policy's spelling. + pub fn label(self) -> &'static str { + match self { + Media::Video => "video", + Media::Image => "image", + Media::Audio => "audio", + Media::World => "world", + Media::Feed => "feed", + Media::Avatar => "avatar", + Media::Film => "film", + } + } + fn from_label(s: &str) -> Option { + Media::ALL.into_iter().find(|m| m.label() == s) + } +} + +/// A component's timebase: `num / den` units per second. For video one unit is one +/// frame, for audio one sample. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Timebase { + pub num: u32, + pub den: u32, +} + +impl Timebase { + /// A policy duration in milliseconds as a count of this timebase's units: + /// `floor(ms · num / (1000 · den))`, 1 where that is 0, and `2^64 − 1` where it is + /// more ([packaging] §4.4). + /// + /// [packaging]: ../../../extensions/packaging.md + pub fn units_from_ms(self, ms: u64) -> u64 { + let u = (ms as u128 * self.num as u128) / (1000 * self.den as u128); + u64::try_from(u).unwrap_or(u64::MAX).max(1) + } + + /// A start on the lead component's timebase `lead`, as a start on this one: + /// `floor(start · num · lead.den / (den · lead.num))`, and `2^64 − 1` where that is + /// more ([packaging] §4.5). The product fits 128 bits. + /// + /// [packaging]: ../../../extensions/packaging.md + pub fn align_from(self, start: u64, lead: Timebase) -> u64 { + let n = start as u128 * self.num as u128 * lead.den as u128; + u64::try_from(n / (self.den as u128 * lead.num as u128)).unwrap_or(u64::MAX) + } +} + +/// One media stream of the package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Component { + pub id: String, + pub media: Media, + pub source: Option, + pub timebase: Timebase, +} + +/// `[segmentation]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Segmentation { + pub target_ms: u64, + pub min_ms: u64, + pub max_ms: u64, + pub scene_cut: String, + pub cut_abs_permille: u64, + pub cut_rel_permille: u64, + pub cut_window: u64, + pub lookahead_frames: u64, + pub closed: bool, +} + +/// What a rung is in its piece's staged order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Base, + Refinement, + Floor, +} + +impl Role { + pub fn label(self) -> &'static str { + match self { + Role::Base => "base", + Role::Refinement => "refinement", + Role::Floor => "floor", + } + } +} + +/// One rung of the ladder: a capability a component's pieces are coded in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rung { + pub id: String, + pub component: String, + pub capability: String, + pub role: Role, + pub refines: Option, + /// The parameters, every default written in, as the canonical JSON carries them. + pub params: Map, +} + +impl Rung { + /// An integer parameter (present after defaults, for the capabilities whose schema + /// defines it). + pub fn param_u64(&self, k: &str) -> Option { + self.params.get(k).and_then(Value::as_u64) + } + /// A list-of-integers parameter. + pub fn param_u64_list(&self, k: &str) -> Option> { + self.params.get(k)?.as_array()?.iter().map(Value::as_u64).collect() + } +} + +/// `[floor]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Floor { + pub supplied_listing: Option, + pub extra_keyframes: bool, +} + +/// One delivery class. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Class { + pub id: String, + pub max_layer: u64, + pub max_kbps: Option, + pub floor_only: bool, +} + +/// A declared span of presentation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Moment { + pub id: String, + pub from_ms: u64, + pub to_ms: u64, + pub tags: Vec, +} + +/// The piece kinds `wai.pack.scenecut/1`'s classification gives ([packaging] §4.6). +/// +/// [packaging]: ../../../extensions/packaging.md +pub const PIECE_KINDS: [&str; 4] = ["natural", "static", "synthetic", "motion_high"]; + +/// A rule's condition: every listed key must hold; an absent key does not constrain. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct When { + pub piece_kind: Option>, + pub moment_tag: Option>, +} + +/// What a rule sets for the pieces it matches; an absent key leaves the value as it was. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Then { + pub ladder: Option>, + pub max_layer: Option, + pub deadline_ms: Option, +} + +/// One rule, applied in file order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + pub id: String, + pub when: When, + pub then: Then, +} + +/// `[classify]`: the thresholds of the piece classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClassifyParams { + pub static_permille: u64, + pub static_mad_milli: u64, + pub synthetic_bins: u64, + pub motion_high_milli: u64, +} + +/// Values the command line may override before the digest is taken. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Overrides { + pub royalty_free_only: Option, +} + +/// A policy, every default written in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Policy { + pub asset_id: String, + pub royalty_free_only: bool, + pub lead: String, + pub components: Vec, + pub segmentation: Segmentation, + pub ladder: Vec, + pub floor: Floor, + /// `live.layer_lag_ms`: one entry per layer. + pub layer_lag_ms: Vec, + pub classes: Vec, + pub moments: Vec, + pub rules: Vec, + pub deadline_ms: u64, + pub classify: ClassifyParams, +} + +// ─────────────────────────────── stage 1: value kinds ─────────────────────────────── + +fn value_kinds(v: &toml::Value, at: &str) -> R<()> { + match v { + toml::Value::Float(_) => Err(PolicyError::new(PolicyReason::Float, at)), + toml::Value::Datetime(_) => Err(PolicyError::new(PolicyReason::Datetime, at)), + toml::Value::Integer(i) if *i < 0 => Err(PolicyError::new(PolicyReason::Negative, at)), + toml::Value::Integer(i) if *i as u64 > MAX_INTEGER => Err(PolicyError::new(PolicyReason::IntegerRange, at)), + toml::Value::Array(a) => { + for (i, x) in a.iter().enumerate() { + value_kinds(x, &format!("{at}[{i}]"))?; + } + Ok(()) + } + toml::Value::Table(t) => table_kinds(t, at), + _ => Ok(()), + } +} + +fn table_kinds(t: &toml::Table, at: &str) -> R<()> { + // toml::Table is ordered by key, bytewise. + for (k, v) in t { + value_kinds(v, &join(at, k))?; + } + Ok(()) +} + +fn join(at: &str, k: &str) -> String { + if at.is_empty() { k.to_string() } else { format!("{at}.{k}") } +} + +// ─────────────────────────────── stage 2: schema ─────────────────────────────── + +/// A table being read against its schema. +struct Tab<'a> { + t: &'a toml::Table, + at: String, +} + +static EMPTY: std::sync::LazyLock = std::sync::LazyLock::new(toml::Table::new); + +impl<'a> Tab<'a> { + fn new(t: &'a toml::Table, at: String) -> Self { + Tab { t, at } + } + fn err(&self, reason: PolicyReason, k: &str) -> PolicyError { + PolicyError::new(reason, join(&self.at, k)) + } + /// Refuse the first key, in byte order, that `allowed` does not hold. + fn keys(&self, allowed: &[&str]) -> R<()> { + match self.t.keys().find(|k| !allowed.contains(&k.as_str())) { + Some(k) => Err(self.err(PolicyReason::UnknownKey, k)), + None => Ok(()), + } + } + fn get(&self, k: &str) -> Option<&'a toml::Value> { + self.t.get(k) + } + fn string(&self, k: &str) -> R> { + match self.get(k) { + None => Ok(None), + Some(toml::Value::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } + fn string_req(&self, k: &str) -> R { + self.string(k)?.ok_or_else(|| self.err(PolicyReason::MissingKey, k)) + } + /// A required, non-empty string of at most 255 bytes: an id. + fn id(&self, k: &str) -> R { + let s = self.string_req(k)?; + if s.is_empty() || s.len() > 255 { + return Err(self.err(PolicyReason::Value, k)); + } + Ok(s) + } + fn uint(&self, k: &str) -> R> { + match self.get(k) { + None => Ok(None), + // Stage 1 admitted only integers in 0..=2^53 − 1. + Some(toml::Value::Integer(i)) => Ok(Some(*i as u64)), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } + /// An integer in `lo..=hi`, `default` when absent (`None`: required). + fn uint_in(&self, k: &str, lo: u64, hi: u64, default: Option) -> R { + let v = match (self.uint(k)?, default) { + (Some(v), _) => v, + (None, Some(d)) => return Ok(d), + (None, None) => return Err(self.err(PolicyReason::MissingKey, k)), + }; + if v < lo || v > hi { + return Err(self.err(PolicyReason::Value, k)); + } + Ok(v) + } + fn uint_opt_in(&self, k: &str, lo: u64, hi: u64) -> R> { + match self.uint(k)? { + None => Ok(None), + Some(v) if v < lo || v > hi => Err(self.err(PolicyReason::Value, k)), + Some(v) => Ok(Some(v)), + } + } + fn boolean(&self, k: &str, default: bool) -> R { + match self.get(k) { + None => Ok(default), + Some(toml::Value::Boolean(b)) => Ok(*b), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } + /// A table member; an absent one reads as empty. + fn table(&self, k: &str) -> R> { + match self.get(k) { + None => Ok(Tab::new(&EMPTY, join(&self.at, k))), + Some(toml::Value::Table(t)) => Ok(Tab::new(t, join(&self.at, k))), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } + fn table_req(&self, k: &str) -> R> { + if self.get(k).is_none() { + return Err(self.err(PolicyReason::MissingKey, k)); + } + self.table(k) + } + /// An array of tables; an absent one reads as empty. + fn tables(&self, k: &str) -> R>> { + match self.get(k) { + None => Ok(Vec::new()), + Some(toml::Value::Array(a)) => a + .iter() + .enumerate() + .map(|(i, v)| match v { + toml::Value::Table(t) => Ok(Tab::new(t, format!("{}[{i}]", join(&self.at, k)))), + _ => Err(self.err(PolicyReason::Type, k)), + }) + .collect(), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } + fn strings(&self, k: &str) -> R>> { + match self.get(k) { + None => Ok(None), + Some(toml::Value::Array(a)) => a + .iter() + .map(|v| match v { + toml::Value::String(s) => Ok(s.clone()), + _ => Err(self.err(PolicyReason::Type, k)), + }) + .collect::>>() + .map(Some), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } + fn uints(&self, k: &str) -> R>> { + match self.get(k) { + None => Ok(None), + Some(toml::Value::Array(a)) => a + .iter() + .map(|v| match v { + toml::Value::Integer(i) => Ok(*i as u64), + _ => Err(self.err(PolicyReason::Type, k)), + }) + .collect::>>() + .map(Some), + Some(_) => Err(self.err(PolicyReason::Type, k)), + } + } +} + +/// One parameter of a capability's `params` table. +enum Param { + /// An integer in `lo..=hi`; `default` when absent, required when `None`. + Int { lo: u64, hi: u64, default: Option }, + /// An integer from `allowed`. + OneOf { allowed: &'static [u64], default: u64 }, + /// A non-empty list of integers in `lo..=hi`. + Ints { lo: u64, hi: u64, default: &'static [u64] }, + /// A string from `allowed`. + Word { allowed: &'static [&'static str], default: &'static str }, +} + +/// The parameters each capability's rung takes ([packaging] §3.3). A capability the +/// table does not list takes none. +/// +/// [packaging]: ../../../extensions/packaging.md +fn param_schema(cap: &str) -> &'static [(&'static str, Param)] { + use Param::*; + match cap { + "wai.video.int_motion" => &[ + ("block", OneOf { allowed: &[4, 8, 16, 32], default: 16 }), + ("search", Int { lo: 0, hi: 32, default: Some(7) }), + ("mv_precision", OneOf { allowed: &[1, 2], default: 2 }), + ("q_candidates", Ints { lo: 1, hi: 255, default: &[8] }), + ("max_sse_px_milli", Int { lo: 0, hi: MAX_INTEGER, default: Some(0) }), + ], + "wai.video.int_refine" => &[("q", Int { lo: 1, hi: 255, default: None })], + "wai.video.av1" | "wai.video.av1.lossless" => &[("source", Word { allowed: &["supplied", "encode"], default: "supplied" })], + "wai.audio.int_layered" => &[("rate", Int { lo: 1, hi: 384_000, default: None })], + "wai.audio.int_refine" => &[ + ("to_rate", Int { lo: 1, hi: 384_000, default: None }), + ("q", Int { lo: 1, hi: 65_535, default: None }), + ], + "wai.audio.flac" => &[("source", Word { allowed: &["encode", "supplied"], default: "encode" })], + _ => &[], + } +} + +fn read_params(rung: &Tab<'_>, cap: &str) -> R> { + let p = rung.table("params")?; + let schema = param_schema(cap); + let names: Vec<&str> = schema.iter().map(|(k, _)| *k).collect(); + p.keys(&names)?; + let mut out = Map::new(); + for (k, kind) in schema { + let v = match kind { + Param::Int { lo, hi, default } => Value::from(p.uint_in(k, *lo, *hi, *default)?), + Param::OneOf { allowed, default } => { + let v = p.uint_in(k, 0, MAX_INTEGER, Some(*default))?; + if !allowed.contains(&v) { + return Err(p.err(PolicyReason::Value, k)); + } + Value::from(v) + } + Param::Ints { lo, hi, default } => { + let v = p.uints(k)?.unwrap_or_else(|| default.to_vec()); + if v.is_empty() || v.iter().any(|x| x < lo || x > hi) { + return Err(p.err(PolicyReason::Value, k)); + } + Value::from(v) + } + Param::Word { allowed, default } => { + let v = p.string(k)?.unwrap_or_else(|| default.to_string()); + if !allowed.contains(&v.as_str()) { + return Err(p.err(PolicyReason::Value, k)); + } + Value::from(v) + } + }; + out.insert(k.to_string(), v); + } + Ok(out) +} + +fn is_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Stage 2: read the document against the schema, defaults written in. `layer_lag_ms` +/// is returned as written; stage 3 checks it and writes its default. +fn read(doc: &toml::Table) -> R<(Policy, Option>)> { + let top = Tab::new(doc, String::new()); + top.keys(&[ + "schema", "asset_id", "royalty_free_only", "lead", "component", "segmentation", "ladder", "floor", "live", + "class", "moment", "rule", "defaults", "classify", + ])?; + if top.string_req("schema")? != SCHEMA { + return Err(top.err(PolicyReason::Schema, "schema")); + } + let asset_id = top.id("asset_id")?; + let royalty_free_only = top.boolean("royalty_free_only", false)?; + let lead = top.id("lead")?; + + if top.get("component").is_none() { + return Err(top.err(PolicyReason::MissingKey, "component")); + } + let mut components = Vec::new(); + for c in top.tables("component")? { + c.keys(&["id", "media", "source", "timebase"])?; + let id = c.id("id")?; + let media = Media::from_label(&c.string_req("media")?).ok_or_else(|| c.err(PolicyReason::Value, "media"))?; + let source = c.string("source")?; + let tb = c.table_req("timebase")?; + tb.keys(&["num", "den"])?; + let num = tb.uint_in("num", 1, u32::MAX as u64, None)? as u32; + let den = tb.uint_in("den", 1, u32::MAX as u64, None)? as u32; + components.push(Component { id, media, source, timebase: Timebase { num, den } }); + } + + let s = top.table("segmentation")?; + s.keys(&[ + "target_ms", "min_ms", "max_ms", "scene_cut", "cut_abs_permille", "cut_rel_permille", "cut_window", + "lookahead_frames", "closed", + ])?; + let segmentation = Segmentation { + target_ms: s.uint_in("target_ms", 0, MAX_INTEGER, Some(2000))?, + min_ms: s.uint_in("min_ms", 0, MAX_INTEGER, Some(1000))?, + max_ms: s.uint_in("max_ms", 0, MAX_INTEGER, Some(4000))?, + scene_cut: { + let v = s.string("scene_cut")?.unwrap_or_else(|| SCENECUT_V1.to_string()); + if v != SCENECUT_V1 { + return Err(s.err(PolicyReason::Value, "scene_cut")); + } + v + }, + cut_abs_permille: s.uint_in("cut_abs_permille", 1, 1000, Some(300))?, + cut_rel_permille: s.uint_in("cut_rel_permille", 0, 1_000_000, Some(3000))?, + cut_window: s.uint_in("cut_window", 1, 64, Some(8))?, + lookahead_frames: s.uint_in("lookahead_frames", 0, 64, Some(6))?, + closed: s.boolean("closed", true)?, + }; + + let mut ladder = Vec::new(); + for r in top.tables("ladder")? { + r.keys(&["id", "component", "capability", "role", "refines", "params"])?; + let id = r.id("id")?; + let component = r.id("component")?; + let capability = r.string_req("capability")?; + let role = match r.string_req("role")?.as_str() { + "base" => Role::Base, + "refinement" => Role::Refinement, + "floor" => Role::Floor, + _ => return Err(r.err(PolicyReason::Value, "role")), + }; + let refines = r.string("refines")?; + let params = read_params(&r, &capability)?; + ladder.push(Rung { id, component, capability, role, refines, params }); + } + + let f = top.table("floor")?; + f.keys(&["supplied_listing", "extra_keyframes"])?; + let supplied_listing = f.string("supplied_listing")?; + if supplied_listing.as_deref().is_some_and(|s| !is_hex64(s)) { + return Err(f.err(PolicyReason::Value, "supplied_listing")); + } + let floor = Floor { supplied_listing, extra_keyframes: f.boolean("extra_keyframes", false)? }; + + let l = top.table("live")?; + l.keys(&["layer_lag_ms"])?; + let lag = l.uints("layer_lag_ms")?; + + let mut classes = Vec::new(); + for c in top.tables("class")? { + c.keys(&["id", "max_layer", "max_kbps", "floor_only"])?; + classes.push(Class { + id: c.id("id")?, + max_layer: c.uint_in("max_layer", 0, 255, Some(0))?, + max_kbps: c.uint_opt_in("max_kbps", 1, u32::MAX as u64)?, + floor_only: c.boolean("floor_only", false)?, + }); + } + + let mut moments = Vec::new(); + for m in top.tables("moment")? { + m.keys(&["id", "from_ms", "to_ms", "tags"])?; + let id = m.id("id")?; + let from_ms = m.uint_in("from_ms", 0, MAX_INTEGER, None)?; + let to_ms = m.uint_in("to_ms", 0, MAX_INTEGER, None)?; + if to_ms <= from_ms { + return Err(m.err(PolicyReason::Value, "to_ms")); + } + let tags = m.strings("tags")?.unwrap_or_default(); + moments.push(Moment { id, from_ms, to_ms, tags }); + } + + let mut rules = Vec::new(); + for r in top.tables("rule")? { + r.keys(&["id", "when", "then"])?; + let id = r.id("id")?; + let w = r.table("when")?; + w.keys(&["piece_kind", "moment_tag"])?; + let piece_kind = w.strings("piece_kind")?; + if piece_kind.as_ref().is_some_and(|k| k.iter().any(|x| !PIECE_KINDS.contains(&x.as_str()))) { + return Err(w.err(PolicyReason::Value, "piece_kind")); + } + let moment_tag = w.strings("moment_tag")?; + let t = r.table("then")?; + t.keys(&["ladder", "max_layer", "deadline_ms"])?; + let then = Then { + ladder: t.strings("ladder")?, + max_layer: t.uint_opt_in("max_layer", 0, 255)?, + deadline_ms: t.uint_opt_in("deadline_ms", 1, u32::MAX as u64)?, + }; + rules.push(Rule { id, when: When { piece_kind, moment_tag }, then }); + } + + let d = top.table("defaults")?; + d.keys(&["deadline_ms"])?; + let deadline_ms = d.uint_in("deadline_ms", 1, u32::MAX as u64, Some(1500))?; + + let c = top.table("classify")?; + c.keys(&["static_permille", "static_mad_milli", "synthetic_bins", "motion_high_milli"])?; + let classify = ClassifyParams { + static_permille: c.uint_in("static_permille", 0, 1000, Some(5))?, + static_mad_milli: c.uint_in("static_mad_milli", 0, MAX_INTEGER, Some(300))?, + synthetic_bins: c.uint_in("synthetic_bins", 0, 64, Some(12))?, + motion_high_milli: c.uint_in("motion_high_milli", 0, MAX_INTEGER, Some(9000))?, + }; + + let policy = Policy { + asset_id, + royalty_free_only, + lead, + components, + segmentation, + ladder, + floor, + layer_lag_ms: Vec::new(), + classes, + moments, + rules, + deadline_ms, + classify, + }; + Ok((policy, lag)) +} + +// ─────────────────────────────── stage 3: constraints ─────────────────────────────── + +fn first_duplicate<'a>(ids: impl Iterator) -> Option<&'a str> { + let mut seen = BTreeSet::new(); + ids.into_iter().find(|id| !seen.insert(*id)) +} + +impl Policy { + /// Parse and check a policy, every default written in ([packaging] §3). + /// + /// [packaging]: ../../../extensions/packaging.md + pub fn parse(text: &str) -> Result { + let doc: toml::Table = text.parse().map_err(|_| PolicyError::new(PolicyReason::Toml, ""))?; + table_kinds(&doc, "")?; + let (mut p, lag) = read(&doc)?; + p.constrain(lag)?; + Ok(p) + } + + /// The effective policy: this one with `o` applied. + pub fn effective(&self, o: &Overrides) -> Policy { + let mut p = self.clone(); + if let Some(b) = o.royalty_free_only { + p.royalty_free_only = b; + } + p + } + + /// The layer of each rung: 0 for a base or a floor, one more than the rung it + /// refines for a refinement. Defined once stage 3 has checked the chains. + pub fn layers(&self) -> BTreeMap<&str, u64> { + let by_id: BTreeMap<&str, &Rung> = self.ladder.iter().map(|r| (r.id.as_str(), r)).collect(); + let mut out = BTreeMap::new(); + for r in &self.ladder { + let mut k = 0u64; + let mut cur = r; + while let Some(up) = cur.refines.as_deref().and_then(|id| by_id.get(id)) { + k += 1; + cur = up; + } + out.insert(r.id.as_str(), k); + } + out + } + + /// The number of layers: one more than the highest rung layer. + pub fn layer_count(&self) -> u64 { + self.layers().values().copied().max().map_or(1, |m| m + 1) + } + + /// The distinct deadline values a refinement may carry: the default and every + /// rule's. + pub fn deadlines(&self) -> BTreeSet { + let mut d: BTreeSet = self.rules.iter().filter_map(|r| r.then.deadline_ms).collect(); + d.insert(self.deadline_ms); + d + } + + fn constrain(&mut self, lag: Option>) -> R<()> { + use PolicyReason as P; + let e = |r: P, at: &str| Err(PolicyError::new(r, at)); + if self.components.is_empty() || self.components.len() > MAX_COMPONENTS { + return e(P::TooManyComponents, "component"); + } + if self.ladder.len() > MAX_RUNGS { + return e(P::TooManyRungs, "ladder"); + } + if self.classes.len() > MAX_CLASSES { + return e(P::TooManyClasses, "class"); + } + if self.rules.len() > MAX_RULES { + return e(P::TooManyRules, "rule"); + } + if self.moments.len() > MAX_MOMENTS { + return e(P::TooManyMoments, "moment"); + } + let dups = [ + ("component", first_duplicate(self.components.iter().map(|c| c.id.as_str()))), + ("ladder", first_duplicate(self.ladder.iter().map(|r| r.id.as_str()))), + ("class", first_duplicate(self.classes.iter().map(|c| c.id.as_str()))), + ("moment", first_duplicate(self.moments.iter().map(|m| m.id.as_str()))), + ("rule", first_duplicate(self.rules.iter().map(|r| r.id.as_str()))), + ]; + if let Some((at, Some(id))) = dups.into_iter().find(|(_, d)| d.is_some()) { + return e(P::DuplicateId, &format!("{at}.{id}")); + } + if !self.components.iter().any(|c| c.id == self.lead) { + return e(P::Lead, "lead"); + } + if !self.segmentation.closed { + return e(P::OpenGop, "segmentation.closed"); + } + let s = &self.segmentation; + if !(1 <= s.min_ms && s.min_ms <= s.target_ms && s.target_ms <= s.max_ms) { + return e(P::PieceBounds, "segmentation"); + } + let comps: BTreeSet<&str> = self.components.iter().map(|c| c.id.as_str()).collect(); + if let Some(r) = self.ladder.iter().find(|r| !comps.contains(r.component.as_str())) { + return e(P::LadderComponent, &format!("ladder.{}", r.id)); + } + if let Some(r) = self.ladder.iter().find(|r| (r.role == Role::Refinement) != r.refines.is_some()) { + return e(P::RefinesRole, &format!("ladder.{}", r.id)); + } + let by_id: BTreeMap<&str, &Rung> = self.ladder.iter().map(|r| (r.id.as_str(), r)).collect(); + if let Some(r) = self.ladder.iter().find(|r| r.refines.as_deref().is_some_and(|t| !by_id.contains_key(t))) { + return e(P::DanglingRefines, &format!("ladder.{}", r.id)); + } + for r in &self.ladder { + let mut seen = BTreeSet::from([r.id.as_str()]); + let mut cur: &Rung = r; + while let Some(up) = cur.refines.as_deref() { + if !seen.insert(up) { + return e(P::RefinesCycle, &format!("ladder.{}", r.id)); + } + cur = by_id[up]; + } + } + for r in self.ladder.iter().filter(|r| r.role == Role::Refinement) { + let mut cur: &Rung = r; + while let Some(up) = cur.refines.as_deref() { + cur = by_id[up]; + if cur.component != r.component { + return e(P::RefinesBase, &format!("ladder.{}", r.id)); + } + } + if cur.role != Role::Base { + return e(P::RefinesBase, &format!("ladder.{}", r.id)); + } + } + if let Some(t) = first_duplicate(self.ladder.iter().filter_map(|r| r.refines.as_deref())) { + return e(P::RefinesBranch, &format!("ladder.{t}")); + } + if let Some(c) = self.components.iter().find(|c| !self.ladder.iter().any(|r| r.component == c.id && r.role == Role::Base)) { + return e(P::NoBase, &format!("component.{}", c.id)); + } + let top = self.layer_count() - 1; + if let Some(c) = self.classes.iter().find(|c| c.max_layer > top) { + return e(P::ClassLayer, &format!("class.{}", c.id)); + } + if let Some(r) = self.rules.iter().find(|r| r.then.max_layer.is_some_and(|m| m > top)) { + return e(P::ClassLayer, &format!("rule.{}", r.id)); + } + for r in &self.rules { + if let Some(ids) = &r.then.ladder { + let rungs: Option> = ids.iter().map(|id| by_id.get(id.as_str()).copied()).collect(); + let ok = rungs.is_some_and(|rs| { + !rs.is_empty() + && rs.iter().all(|x| x.component == rs[0].component) + && rs.iter().any(|x| x.role == Role::Base) + }); + if !ok { + return e(P::RuleLadder, &format!("rule.{}", r.id)); + } + } + } + let n = self.layer_count() as usize; + let lag = lag.unwrap_or_else(|| vec![0; n]); + if lag.len() != n || lag[0] != 0 || lag.windows(2).any(|w| w[1] < w[0]) { + return e(P::LayerLag, "live.layer_lag_ms"); + } + self.layer_lag_ms = lag; + let deadlines = self.deadlines(); + if deadlines.len() > MAX_DEADLINES { + return e(P::TooManyDeadlines, "rule"); + } + for &d in &deadlines { + if self.layer_lag_ms.iter().skip(1).any(|&lag| d < lag + 1) { + return e(P::DeadlineUnreachable, "live.layer_lag_ms"); + } + } + Ok(()) + } + + /// The effective policy as JSON ([packaging] §3.2): every member the schema + /// defines, defaults written in; an optional member with no default (`source`, + /// `supplied_listing`, `max_kbps`, `refines`, and a rule's `when` and `then` keys) is + /// omitted when absent. Arrays of tables keep their file order. + /// + /// [packaging]: ../../../extensions/packaging.md + pub fn to_value(&self) -> Value { + let mut top = Map::new(); + top.insert("schema".into(), SCHEMA.into()); + top.insert("asset_id".into(), self.asset_id.clone().into()); + top.insert("royalty_free_only".into(), self.royalty_free_only.into()); + top.insert("lead".into(), self.lead.clone().into()); + let comps = self + .components + .iter() + .map(|c| { + let mut m = Map::new(); + m.insert("id".into(), c.id.clone().into()); + m.insert("media".into(), c.media.label().into()); + if let Some(s) = &c.source { + m.insert("source".into(), s.clone().into()); + } + let mut tb = Map::new(); + tb.insert("num".into(), c.timebase.num.into()); + tb.insert("den".into(), c.timebase.den.into()); + m.insert("timebase".into(), tb.into()); + Value::Object(m) + }) + .collect::>(); + top.insert("component".into(), comps.into()); + let s = &self.segmentation; + let mut seg = Map::new(); + seg.insert("target_ms".into(), s.target_ms.into()); + seg.insert("min_ms".into(), s.min_ms.into()); + seg.insert("max_ms".into(), s.max_ms.into()); + seg.insert("scene_cut".into(), s.scene_cut.clone().into()); + seg.insert("cut_abs_permille".into(), s.cut_abs_permille.into()); + seg.insert("cut_rel_permille".into(), s.cut_rel_permille.into()); + seg.insert("cut_window".into(), s.cut_window.into()); + seg.insert("lookahead_frames".into(), s.lookahead_frames.into()); + seg.insert("closed".into(), s.closed.into()); + top.insert("segmentation".into(), seg.into()); + let ladder = self + .ladder + .iter() + .map(|r| { + let mut m = Map::new(); + m.insert("id".into(), r.id.clone().into()); + m.insert("component".into(), r.component.clone().into()); + m.insert("capability".into(), r.capability.clone().into()); + m.insert("role".into(), r.role.label().into()); + if let Some(t) = &r.refines { + m.insert("refines".into(), t.clone().into()); + } + m.insert("params".into(), Value::Object(r.params.clone())); + Value::Object(m) + }) + .collect::>(); + top.insert("ladder".into(), ladder.into()); + let mut floor = Map::new(); + if let Some(l) = &self.floor.supplied_listing { + floor.insert("supplied_listing".into(), l.clone().into()); + } + floor.insert("extra_keyframes".into(), self.floor.extra_keyframes.into()); + top.insert("floor".into(), floor.into()); + let mut live = Map::new(); + live.insert("layer_lag_ms".into(), self.layer_lag_ms.clone().into()); + top.insert("live".into(), live.into()); + let classes = self + .classes + .iter() + .map(|c| { + let mut m = Map::new(); + m.insert("id".into(), c.id.clone().into()); + m.insert("max_layer".into(), c.max_layer.into()); + if let Some(k) = c.max_kbps { + m.insert("max_kbps".into(), k.into()); + } + m.insert("floor_only".into(), c.floor_only.into()); + Value::Object(m) + }) + .collect::>(); + top.insert("class".into(), classes.into()); + let moments = self + .moments + .iter() + .map(|x| { + let mut m = Map::new(); + m.insert("id".into(), x.id.clone().into()); + m.insert("from_ms".into(), x.from_ms.into()); + m.insert("to_ms".into(), x.to_ms.into()); + m.insert("tags".into(), x.tags.clone().into()); + Value::Object(m) + }) + .collect::>(); + top.insert("moment".into(), moments.into()); + let rules = self + .rules + .iter() + .map(|r| { + let mut when = Map::new(); + if let Some(k) = &r.when.piece_kind { + when.insert("piece_kind".into(), k.clone().into()); + } + if let Some(t) = &r.when.moment_tag { + when.insert("moment_tag".into(), t.clone().into()); + } + let mut then = Map::new(); + if let Some(l) = &r.then.ladder { + then.insert("ladder".into(), l.clone().into()); + } + if let Some(m) = r.then.max_layer { + then.insert("max_layer".into(), m.into()); + } + if let Some(d) = r.then.deadline_ms { + then.insert("deadline_ms".into(), d.into()); + } + let mut m = Map::new(); + m.insert("id".into(), r.id.clone().into()); + m.insert("when".into(), when.into()); + m.insert("then".into(), then.into()); + Value::Object(m) + }) + .collect::>(); + top.insert("rule".into(), rules.into()); + let mut d = Map::new(); + d.insert("deadline_ms".into(), self.deadline_ms.into()); + top.insert("defaults".into(), d.into()); + let c = &self.classify; + let mut cl = Map::new(); + cl.insert("static_permille".into(), c.static_permille.into()); + cl.insert("static_mad_milli".into(), c.static_mad_milli.into()); + cl.insert("synthetic_bins".into(), c.synthetic_bins.into()); + cl.insert("motion_high_milli".into(), c.motion_high_milli.into()); + top.insert("classify".into(), cl.into()); + Value::Object(top) + } + + /// RFC 8785 over [`Policy::to_value`]. + pub fn canonical_json(&self) -> String { + // Every integer was checked against 2^53 − 1 and every value is a string, a + // boolean, an integer, an array or an object, so the subset holds it. + wai::jcs::jcs(&self.to_value()).expect("a parsed policy is inside the canonical subset") + } + + /// `BLAKE3("wai:pack-policy\x01" ‖ canonical JSON)`. + pub fn digest(&self) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_POLICY); + h.update(self.canonical_json().as_bytes()); + *h.finalize().as_bytes() + } + + /// The component `id`. + pub fn component(&self, id: &str) -> Option<&Component> { + self.components.iter().find(|c| c.id == id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MIN: &str = r#" +schema = "wai.pack.policy/1" +asset_id = "a" +lead = "v" +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" +"#; + + fn code(s: &str) -> &'static str { + Policy::parse(s).map(|_| "ok").unwrap_or_else(|e| e.code()) + } + + #[test] + fn a_minimal_policy_gets_every_default() { + let p = Policy::parse(MIN).unwrap(); + assert_eq!(p.segmentation.target_ms, 2000); + assert_eq!(p.segmentation.lookahead_frames, 6); + assert_eq!(p.deadline_ms, 1500); + assert_eq!(p.layer_lag_ms, vec![0]); + assert_eq!(p.ladder[0].param_u64("block"), Some(16)); + assert_eq!(p.ladder[0].param_u64_list("q_candidates"), Some(vec![8])); + // The digest covers the effective policy: an override changes it. + let o = Overrides { royalty_free_only: Some(true) }; + assert_ne!(p.digest(), p.effective(&o).digest()); + assert_eq!(p.digest(), p.effective(&Overrides::default()).digest()); + } + + /// Each value kind canonical JSON cannot carry is refused wherever it sits, even + /// under a key the schema does not define. + #[test] + fn value_kinds_are_refused_before_keys() { + assert_eq!(code(&format!("{MIN}\nzzz = 1.5")), "policy-float"); + assert_eq!(code(&format!("{MIN}\nzzz = 1979-05-27")), "policy-datetime"); + assert_eq!(code(&format!("{MIN}\nzzz = -1")), "policy-negative"); + assert_eq!(code(&format!("{MIN}\nzzz = 9007199254740992")), "policy-integer-range"); + assert_eq!(code(&format!("{MIN}\nzzz = 9007199254740991")), "policy-unknown-key"); + assert_eq!(code("schema = "), "policy-toml"); + } + + #[test] + fn units_and_alignment_round_down() { + let tb = Timebase { num: 30000, den: 1001 }; + assert_eq!(tb.units_from_ms(1000), 29); + assert_eq!(tb.units_from_ms(0), 1); + let a = Timebase { num: 48000, den: 1 }; + assert_eq!(a.align_from(50, Timebase { num: 25, den: 1 }), 96000); + assert_eq!(tb.align_from(3, Timebase { num: 25, den: 1 }), 3); + } +} diff --git a/wai/wai-pack/src/scenecut.rs b/wai/wai-pack/src/scenecut.rs new file mode 100644 index 000000000..cdbb918f3 --- /dev/null +++ b/wai/wai-pack/src/scenecut.rs @@ -0,0 +1,389 @@ +//! `wai.pack.scenecut/1` ([packaging] §4.3): piece boundaries as a pure function of +//! the frames and the policy, decided with a bounded lookahead so that a live and an +//! on-demand segmenter with equal lookahead cut alike. +//! +//! All integer. For frame `f` (RGB8, `w × h`): +//! +//! 1. Luma `Y = (77R + 150G + 29B + 128) >> 8`; histogram `H_f` of 64 bins, `Y >> 2`. +//! 2. `D(a, b) = (Σ_i |H_a[i] − H_b[i]|) · 1000 / (2wh)`, rounded down: the share of +//! pixels, in thousandths, whose bin differs. The score `s_f = D(f, f − 1)`, `f ≥ 1`. +//! 3. The current piece starts at `p`; `len = f − p`. Its window `W_f` is the scores +//! `s_g`, `p < g < f`, the last `cut_window` of them. `m_f` is the lower median of +//! `W_f` (sorted ascending, the element at index `(|W_f| − 1) / 2`). +//! 4. **Hard cut** at `f`: `len ≥ min`, `s_f ≥ cut_abs`, and `|W_f| < 3` or +//! `s_f · 1000 ≥ cut_rel · m_f`. +//! 5. **Soft cut** at `f`: `len ≥ target` and `2 · s_f ≥ cut_abs`. +//! 6. **Return clause.** A cut at `f` is rejected when `D(f + k, f − 1) < cut_abs` for some +//! `k` in `1..=min(L, last − f)`: the content came back within `L` frames, so `f` +//! starts a flash. +//! 7. **Revisit clause.** A cut at `f` is rejected when `D(f, f − 1 − j) < cut_abs` for +//! some `j` in `1..=min(L, f − 1)`: `f` returns to content seen within `L` frames, so +//! it ends a flash. The frames before `p` count. +//! 8. A hard or soft cut that clauses 6 and 7 do not reject starts a piece at `f` +//! (flag `SCENE_CUT`). Otherwise, when `len ≥ max`, a piece starts at `f` (flag +//! `FORCED`). +//! +//! `L` is `lookahead_frames` and `last` the index of the source's last frame. A +//! decision at `f` reads no frame after `f + L`, so [`Segmenter::push`] decides frame +//! `f` as soon as frame `f + L` arrives, and [`Segmenter::finish`] decides the rest +//! once `last` is known. Every piece has `min ≤ len ≤ max`, except the source's last, +//! which may be shorter than `min`. There is one implementation, used by both +//! on-demand and live packing. +//! +//! [packaging]: ../../../extensions/packaging.md + +use crate::policy::{Segmentation, Timebase}; +use std::collections::VecDeque; + +/// Histogram bins. +pub const BINS: usize = 64; +/// A luma histogram. +pub type Hist = [u32; BINS]; + +/// A piece's first frame follows a scene cut. +pub const FLAG_SCENE_CUT: u8 = 1; +/// A piece's first frame was forced at `max`. +pub const FLAG_FORCED: u8 = 2; + +/// The domain of the plan digest. +pub const DOMAIN_PLAN: &[u8] = b"wai:pack-plan\x01"; + +/// BT.601-weighted integer luma of one RGB8 pixel. +#[inline] +pub fn luma(r: u8, g: u8, b: u8) -> u8 { + ((77 * r as u32 + 150 * g as u32 + 29 * b as u32 + 128) >> 8) as u8 +} + +/// The luma plane of an RGB8 frame. +pub fn luma_plane(rgb: &[u8]) -> Vec { + rgb.as_chunks::<3>().0.iter().map(|p| luma(p[0], p[1], p[2])).collect() +} + +/// The 64-bin histogram of a luma plane. +pub fn histogram(y: &[u8]) -> Hist { + let mut h = [0u32; BINS]; + for &v in y { + h[(v >> 2) as usize] += 1; + } + h +} + +/// `D(a, b)`: the share of `wh` pixels, in thousandths, whose bin differs, rounded down. +pub fn distance(a: &Hist, b: &Hist, wh: u64) -> u64 { + let sum: u64 = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum * 1000 / (2 * wh) +} + +/// `wai.pack.scenecut/1`'s parameters, in frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SegParams { + pub min: u64, + pub target: u64, + pub max: u64, + pub cut_abs: u64, + pub cut_rel: u64, + pub window: usize, + pub lookahead: u64, +} + +impl SegParams { + /// The policy's segmentation on a component's timebase (one unit per frame). + pub fn from_policy(s: &Segmentation, tb: Timebase) -> Self { + SegParams { + min: tb.units_from_ms(s.min_ms), + target: tb.units_from_ms(s.target_ms), + max: tb.units_from_ms(s.max_ms), + cut_abs: s.cut_abs_permille, + cut_rel: s.cut_rel_permille, + window: s.cut_window as usize, + lookahead: s.lookahead_frames, + } + } +} + +/// What classification reads of a piece ([packaging] §4.6), gathered while segmenting. +/// +/// [packaging]: ../../../extensions/packaging.md +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PieceStats { + /// `Σ |Y_f − Y_{f−1}|` over every pixel of every consecutive pair in the piece. + pub abs_diff: u64, + /// Pixels of consecutive pairs whose luma differs. + pub changed: u64, + /// `wh · (n − 1)`: the pixels compared. + pub compared: u64, + /// Non-empty histogram bins of the piece's first frame. + pub occupancy: u32, +} + +/// A closed piece of a component. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Piece { + /// First frame. + pub start: u64, + /// Frames. + pub len: u64, + /// `FLAG_SCENE_CUT`, `FLAG_FORCED`, or 0 for the source's first piece. + pub flags: u8, + pub stats: PieceStats, +} + +/// What the segmenter keeps of a frame while it may still be read. +#[derive(Clone)] +struct Rec { + hist: Hist, + /// `s_f`; 0 for frame 0. + score: u64, + /// `Σ |Y_f − Y_{f−1}|` and the changed count of the pair `(f − 1, f)`. + abs_diff: u64, + changed: u64, + occupancy: u32, +} + +/// The streaming segmenter: frames in, closed pieces out. +pub struct Segmenter { + p: SegParams, + wh: u64, + /// Frames `base..n`. + recs: VecDeque, + base: u64, + /// Frames pushed. + n: u64, + /// The next frame to decide. + next: u64, + start: u64, + flags: u8, + stats: PieceStats, + window: VecDeque, + prev_y: Vec, + /// Every frame's score, in order (frame 0's is 0). + scores: Vec, +} + +impl Segmenter { + pub fn new(p: SegParams, w: u32, h: u32) -> Self { + Segmenter { + p, + wh: w as u64 * h as u64, + recs: VecDeque::new(), + base: 0, + n: 0, + next: 0, + start: 0, + flags: 0, + stats: PieceStats::default(), + window: VecDeque::new(), + prev_y: Vec::new(), + scores: Vec::new(), + } + } + + /// Every frame's score so far, frame 0's as 0. + pub fn scores(&self) -> &[u64] { + &self.scores + } + + fn rec(&self, f: u64) -> &Rec { + &self.recs[(f - self.base) as usize] + } + + /// Push the next RGB8 frame; returns the pieces this closed. + pub fn push(&mut self, rgb: &[u8]) -> Vec { + let y = luma_plane(rgb); + let hist = histogram(&y); + let (mut abs_diff, mut changed, mut score) = (0, 0, 0); + if self.n > 0 { + for (a, b) in y.iter().zip(&self.prev_y) { + let d = a.abs_diff(*b) as u64; + abs_diff += d; + changed += (d != 0) as u64; + } + score = distance(&hist, &self.rec(self.n - 1).hist, self.wh); + } + let occupancy = hist.iter().filter(|&&c| c != 0).count() as u32; + self.recs.push_back(Rec { hist, score, abs_diff, changed, occupancy }); + self.scores.push(score); + self.prev_y = y; + self.n += 1; + let mut out = Vec::new(); + while self.next + self.p.lookahead < self.n { + self.decide(None, &mut out); + } + out + } + + /// The source has ended: decide the remaining frames and close the last piece. + pub fn finish(mut self) -> Vec { + let mut out = Vec::new(); + if self.n == 0 { + return out; + } + let last = self.n - 1; + while self.next < self.n { + self.decide(Some(last), &mut out); + } + out.push(Piece { start: self.start, len: self.n - self.start, flags: self.flags, stats: self.stats }); + out + } + + fn rejected(&self, f: u64, last: Option) -> bool { + let l = self.p.lookahead; + let ahead = last.map_or(l, |last| l.min(last - f)); + let cut = self.p.cut_abs; + let prev = &self.rec(f - 1).hist; + if (1..=ahead).any(|k| distance(&self.rec(f + k).hist, prev, self.wh) < cut) { + return true; + } + let cur = &self.rec(f).hist; + (1..=l.min(f - 1)).any(|j| distance(cur, &self.rec(f - 1 - j).hist, self.wh) < cut) + } + + fn decide(&mut self, last: Option, out: &mut Vec) { + let f = self.next; + self.next += 1; + if f == 0 { + self.stats.occupancy = self.rec(0).occupancy; + } else { + let p = self.p; + let len = f - self.start; + let s = self.rec(f).score; + let hard = len >= p.min + && s >= p.cut_abs + && (self.window.len() < 3 || s * 1000 >= p.cut_rel * lower_median(&self.window)); + let soft = len >= p.target && 2 * s >= p.cut_abs; + let flag = if (hard || soft) && !self.rejected(f, last) { + Some(FLAG_SCENE_CUT) + } else if len >= p.max { + Some(FLAG_FORCED) + } else { + None + }; + match flag { + Some(flag) => { + out.push(Piece { start: self.start, len, flags: self.flags, stats: self.stats }); + self.start = f; + self.flags = flag; + self.stats = PieceStats { occupancy: self.rec(f).occupancy, ..PieceStats::default() }; + self.window.clear(); + } + None => { + let (abs_diff, changed) = (self.rec(f).abs_diff, self.rec(f).changed); + self.stats.abs_diff += abs_diff; + self.stats.changed += changed; + self.stats.compared += self.wh; + self.window.push_back(s); + if self.window.len() > p.window { + self.window.pop_front(); + } + } + } + } + // Keep frames from next − 1 − L on: the revisit clause reads that far back. + let keep = self.next.saturating_sub(1 + self.p.lookahead); + while self.base < keep { + self.recs.pop_front(); + self.base += 1; + } + } +} + +fn lower_median(w: &VecDeque) -> u64 { + let mut v: Vec = w.iter().copied().collect(); + v.sort_unstable(); + v[(v.len() - 1) / 2] +} + +/// Segment frames held in memory: the pieces and every frame's score. +pub fn segment(p: SegParams, w: u32, h: u32, frames: impl IntoIterator>) -> (Vec, Vec) { + let mut s = Segmenter::new(p, w, h); + let mut pieces = Vec::new(); + for f in frames { + pieces.extend(s.push(f.as_ref())); + } + let scores = s.scores().to_vec(); + pieces.extend(s.finish()); + (pieces, scores) +} + +/// The plan digest ([packaging] §4.2): `BLAKE3("wai:pack-plan\x01" ‖ u64 LE n ‖ n × +/// u64 LE start)`. +/// +/// [packaging]: ../../../extensions/packaging.md +pub fn plan_digest(starts: &[u64]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_PLAN); + h.update(&(starts.len() as u64).to_le_bytes()); + for s in starts { + h.update(&s.to_le_bytes()); + } + *h.finalize().as_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + const W: u32 = 8; + const H: u32 = 8; + + /// A flat grey frame of luma level `v` (R = G = B, so `Y = v`). + fn flat(v: u8) -> Vec { + vec![v; (W * H * 3) as usize] + } + + fn params(lookahead: u64) -> SegParams { + SegParams { min: 4, target: 8, max: 12, cut_abs: 300, cut_rel: 3000, window: 8, lookahead } + } + + fn starts(p: SegParams, frames: &[Vec]) -> Vec { + segment(p, W, H, frames).0.iter().map(|x| x.start).collect() + } + + #[test] + fn distance_is_the_moved_share_in_thousandths() { + let (a, b) = (histogram(&[0; 64]), histogram(&[255; 64])); + assert_eq!(distance(&a, &b, 64), 1000); + assert_eq!(distance(&a, &a, 64), 0); + let half: Vec = (0..64).map(|i| if i < 32 { 0 } else { 255 }).collect(); + assert_eq!(distance(&a, &histogram(&half), 64), 500); + } + + /// A flash of exactly L frames is not a cut (the piece runs to `max`); with a + /// lookahead one frame shorter, the flash's first frame cuts. + #[test] + fn a_flash_of_lookahead_frames_is_not_cut() { + let mut f: Vec> = (0..6).map(|_| flat(40)).collect(); + f.extend((0..3).map(|_| flat(200))); + f.extend((0..6).map(|_| flat(40))); + assert_eq!(starts(params(3), &f), vec![0, 12]); + assert_eq!(starts(params(2), &f), vec![0, 6]); + } + + /// A cut is hard only once the piece has `min` frames; a piece never passes `max`. + #[test] + fn min_and_max_bound_every_piece() { + let mut f: Vec> = (0..2).map(|_| flat(40)).collect(); + f.extend((0..30).map(|_| flat(200))); + let (pieces, _) = segment(params(2), W, H, &f); + let got: Vec<(u64, u64, u8)> = pieces.iter().map(|p| (p.start, p.len, p.flags)).collect(); + assert_eq!(got, vec![(0, 12, 0), (12, 12, FLAG_FORCED), (24, 8, FLAG_FORCED)]); + } + + /// Pushed one at a time, the pieces come out as soon as their end is decided, and + /// are those `segment` gives. + #[test] + fn streaming_decides_after_lookahead() { + let mut f: Vec> = (0..6).map(|_| flat(40)).collect(); + f.extend((0..10).map(|_| flat(200))); + let mut s = Segmenter::new(params(3), W, H); + let mut closed_at = Vec::new(); + for (i, x) in f.iter().enumerate() { + if !s.push(x).is_empty() { + closed_at.push(i); + } + } + // The cut at 6 is decided when frame 6 + 3 arrives. + assert_eq!(closed_at, vec![9]); + assert_eq!(plan_digest(&[0, 6]), plan_digest(&starts(params(3), &f))); + } +} diff --git a/wai/wai-pack/src/source/mod.rs b/wai/wai-pack/src/source/mod.rs new file mode 100644 index 000000000..f5d9ab836 --- /dev/null +++ b/wai/wai-pack/src/source/mod.rs @@ -0,0 +1,88 @@ +//! Source readers ([packaging] §4.7): Y4M video to RGB8 frames, and WAV (PCM s16le) +//! audio. Each refuses what it does not read with a named reason. +//! +//! [packaging]: ../../../../extensions/packaging.md + +pub mod wav; +pub mod y4m; + +/// Why a source was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceReason { + /// A Y4M stream header that is not `YUV4MPEG2` with a width and a height. + Y4mHeader, + /// An interlaced Y4M stream or frame (`I` other than `p`). + Y4mInterlaced, + /// A Y4M colour space other than 8-bit 4:2:0 or 4:4:4. + Y4mColorspace, + /// A Y4M `FRAME` line that sets the width, height or colour space. + Y4mSizeChange, + /// A Y4M frame that ends before its planes do, or a line that is not `FRAME`. + Y4mTruncated, + /// A WAV file that is not RIFF/WAVE PCM with 16-bit samples. + WavFormat, + /// A WAV file that ends before a chunk it declares. + WavTruncated, +} + +impl SourceReason { + pub fn code(self) -> &'static str { + match self { + SourceReason::Y4mHeader => "y4m-header", + SourceReason::Y4mInterlaced => "y4m-interlaced", + SourceReason::Y4mColorspace => "y4m-colorspace", + SourceReason::Y4mSizeChange => "y4m-size-change", + SourceReason::Y4mTruncated => "y4m-truncated", + SourceReason::WavFormat => "wav-format", + SourceReason::WavTruncated => "wav-truncated", + } + } +} + +/// A source refusal, or an I/O failure reading the source. +#[derive(Debug)] +pub enum SourceError { + Refused(SourceReason), + Io(std::io::Error), +} + +impl SourceError { + /// The refusal's name; `io` for an I/O failure. + pub fn code(&self) -> &'static str { + match self { + SourceError::Refused(r) => r.code(), + SourceError::Io(_) => "io", + } + } +} + +impl std::fmt::Display for SourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SourceError::Refused(r) => f.write_str(r.code()), + SourceError::Io(e) => write!(f, "io: {e}"), + } + } +} + +impl std::error::Error for SourceError {} + +impl From for SourceError { + fn from(e: std::io::Error) -> Self { + SourceError::Io(e) + } +} + +impl From for SourceError { + fn from(r: SourceReason) -> Self { + SourceError::Refused(r) + } +} + +/// One RGB8 frame: `w × h` pixels, rows top to bottom, R, G, B bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub w: u32, + pub h: u32, + pub rgb: Vec, +} diff --git a/wai/wai-pack/src/source/wav.rs b/wai/wai-pack/src/source/wav.rs new file mode 100644 index 000000000..0f2693e1b --- /dev/null +++ b/wai/wai-pack/src/source/wav.rs @@ -0,0 +1,152 @@ +//! WAV audio: RIFF/WAVE with a PCM `fmt ` chunk (format tag 1) of 16-bit samples, +//! read a block at a time as interleaved `i16` ([packaging] §4.7). Anything else is +//! refused `wav-format`; a chunk that ends early, `wav-truncated`. +//! +//! [packaging]: ../../../../extensions/packaging.md + +use super::{SourceError, SourceReason}; +use std::io::Read; + +/// The stream's format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WavFormat { + pub channels: u16, + pub rate: u32, +} + +/// A WAV stream positioned at its sample data. +pub struct WavReader { + r: R, + pub format: WavFormat, + /// Sample frames not yet read. + left: u64, +} + +fn refuse(r: SourceReason) -> Result { + Err(SourceError::Refused(r)) +} + +fn read_exact(r: &mut R, b: &mut [u8]) -> Result<(), SourceError> { + r.read_exact(b).map_err(|e| match e.kind() { + std::io::ErrorKind::UnexpectedEof => SourceError::Refused(SourceReason::WavTruncated), + _ => SourceError::Io(e), + }) +} + +/// Skip `n` bytes of a chunk. +fn skip(r: &mut R, n: u64) -> Result<(), SourceError> { + let got = std::io::copy(&mut r.take(n), &mut std::io::sink())?; + if got != n { + return refuse(SourceReason::WavTruncated); + } + Ok(()) +} + +impl WavReader { + /// Read the RIFF header and every chunk up to `data`. + pub fn new(mut r: R) -> Result { + let mut h = [0u8; 12]; + read_exact(&mut r, &mut h).map_err(|_| SourceError::Refused(SourceReason::WavFormat))?; + if &h[0..4] != b"RIFF" || &h[8..12] != b"WAVE" { + return refuse(SourceReason::WavFormat); + } + let mut format = None; + loop { + let mut c = [0u8; 8]; + read_exact(&mut r, &mut c)?; + let len = u32::from_le_bytes([c[4], c[5], c[6], c[7]]) as u64; + match &c[0..4] { + b"fmt " => { + if !(16..=40).contains(&len) { + return refuse(SourceReason::WavFormat); + } + let mut f = vec![0u8; len as usize]; + read_exact(&mut r, &mut f)?; + let tag = u16::from_le_bytes([f[0], f[1]]); + let channels = u16::from_le_bytes([f[2], f[3]]); + let rate = u32::from_le_bytes([f[4], f[5], f[6], f[7]]); + let align = u16::from_le_bytes([f[12], f[13]]); + let bits = u16::from_le_bytes([f[14], f[15]]); + if tag != 1 || bits != 16 || channels == 0 || rate == 0 || align != 2 * channels { + return refuse(SourceReason::WavFormat); + } + format = Some(WavFormat { channels, rate }); + if len % 2 == 1 { + skip(&mut r, 1)?; + } + } + b"data" => { + let Some(format) = format else { + return refuse(SourceReason::WavFormat); + }; + let frame = 2 * format.channels as u64; + if !len.is_multiple_of(frame) { + return refuse(SourceReason::WavFormat); + } + return Ok(WavReader { r, format, left: len / frame }); + } + _ => skip(&mut r, len + (len & 1))?, + } + } + } + + /// Sample frames not yet read. + pub fn remaining(&self) -> u64 { + self.left + } + + /// The next block of at most `frames` sample frames, interleaved; empty at the end. + pub fn next_block(&mut self, frames: u64) -> Result, SourceError> { + let n = frames.min(self.left); + let mut b = vec![0u8; (n * 2 * self.format.channels as u64) as usize]; + read_exact(&mut self.r, &mut b)?; + self.left -= n; + Ok(b.as_chunks::<2>().0.iter().map(|s| i16::from_le_bytes(*s)).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) fn wav(tag: u16, channels: u16, bits: u16, data: &[u8]) -> Vec { + let mut f = Vec::new(); + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&channels.to_le_bytes()); + f.extend_from_slice(&8000u32.to_le_bytes()); + f.extend_from_slice(&(8000 * channels as u32 * bits as u32 / 8).to_le_bytes()); + f.extend_from_slice(&(channels * bits / 8).to_le_bytes()); + f.extend_from_slice(&bits.to_le_bytes()); + let mut b = b"RIFF\0\0\0\0WAVEfmt ".to_vec(); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&f); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(data); + b + } + + fn code(b: &[u8]) -> &'static str { + let read = || -> Result, SourceError> { + let mut r = WavReader::new(b)?; + let n = r.remaining(); + r.next_block(n) + }; + read().map(|_| "ok").unwrap_or_else(|e| e.code()) + } + + #[test] + fn pcm16_reads_and_the_rest_is_refused() { + let s = wav(1, 2, 16, &[1, 0, 255, 255, 2, 0, 0, 128]); + let mut r = WavReader::new(&s[..]).unwrap(); + assert_eq!(r.format, WavFormat { channels: 2, rate: 8000 }); + assert_eq!(r.next_block(9).unwrap(), vec![1, -1, 2, i16::MIN]); + assert_eq!(code(&wav(3, 1, 32, &[0; 4])), "wav-format"); + assert_eq!(code(&wav(1, 1, 8, &[0; 4])), "wav-format"); + assert_eq!(code(&wav(1, 1, 24, &[0; 6])), "wav-format"); + assert_eq!(code(b"RIFX\0\0\0\0WAVE"), "wav-format"); + let mut t = wav(1, 1, 16, &[0; 4]); + t.truncate(t.len() - 1); + assert_eq!(code(&t), "wav-truncated"); + } +} diff --git a/wai/wai-pack/src/source/y4m.rs b/wai/wai-pack/src/source/y4m.rs new file mode 100644 index 000000000..2b29fc1c8 --- /dev/null +++ b/wai/wai-pack/src/source/y4m.rs @@ -0,0 +1,232 @@ +//! Y4M (`YUV4MPEG2`) video, read a frame at a time and converted to RGB8 ([packaging] +//! §4.7). +//! +//! Accepted: 8-bit 4:2:0 (`C420jpeg`, `C420paldv`, `C420mpeg2`, `C420`, or no `C` +//! parameter) and 4:4:4 (`C444`), progressive (`Ip` or no `I`). The conversion is +//! BT.709 limited range in integers, with 4:2:0 chroma taken at the nearest sample +//! (`(y >> 1, x >> 1)`): +//! +//! ```text +//! R = clip((298(Y−16) + 459(Cr−128) + 128) >> 8) +//! G = clip((298(Y−16) − 55(Cb−128) − 136(Cr−128) + 128) >> 8) +//! B = clip((298(Y−16) + 541(Cb−128) + 128) >> 8) +//! ``` +//! +//! where `>>` is the arithmetic shift and `clip` clamps to 0..=255. +//! +//! [packaging]: ../../../../extensions/packaging.md + +use super::{Frame, SourceError, SourceReason}; +use std::io::{BufRead, BufReader, Read}; + +/// Chroma layout of the stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Chroma { + C420, + C444, +} + +/// The stream header's parameters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Header { + pub w: u32, + pub h: u32, + /// The frame rate `F:`, when the header gives one. + pub rate: Option<(u32, u32)>, + pub chroma: Chroma, +} + +/// The most pixels a frame may hold (`w·h`), so a header never sizes an allocation +/// past what a 2^26-element RGB frame needs. +pub const MAX_PIXELS: u64 = (1 << 26) / 3; + +/// A Y4M stream, read one frame at a time. +pub struct Y4mReader { + r: BufReader, + pub header: Header, + buf: Vec, +} + +fn refuse(r: SourceReason) -> Result { + Err(SourceError::Refused(r)) +} + +/// Read one `\n`-terminated line, at most 1024 bytes. `None` at a clean end of stream. +fn line(r: &mut BufReader, at_end: SourceReason) -> Result>, SourceError> { + let mut l = Vec::new(); + let n = r.by_ref().take(1025).read_until(b'\n', &mut l)?; + if n == 0 { + return Ok(None); + } + if l.last() != Some(&b'\n') { + return refuse(at_end); + } + l.pop(); + Ok(Some(l)) +} + +fn parse_u32(s: &[u8]) -> Option { + if s.is_empty() || s.len() > 10 || !s.iter().all(u8::is_ascii_digit) { + return None; + } + std::str::from_utf8(s).ok()?.parse().ok() +} + +impl Y4mReader { + /// Read and check the stream header. + pub fn new(r: R) -> Result { + let mut r = BufReader::new(r); + let l = line(&mut r, SourceReason::Y4mHeader)?.ok_or(SourceError::Refused(SourceReason::Y4mHeader))?; + let mut parts = l.split(|&b| b == b' '); + if parts.next() != Some(&b"YUV4MPEG2"[..]) { + return refuse(SourceReason::Y4mHeader); + } + let (mut w, mut h, mut rate, mut chroma) = (None, None, None, Chroma::C420); + for p in parts.filter(|p| !p.is_empty()) { + let v = &p[1..]; + match p[0] { + b'W' => w = Some(parse_u32(v).ok_or(SourceError::Refused(SourceReason::Y4mHeader))?), + b'H' => h = Some(parse_u32(v).ok_or(SourceError::Refused(SourceReason::Y4mHeader))?), + b'F' => { + let mut it = v.split(|&b| b == b':'); + let (n, d) = (it.next().and_then(parse_u32), it.next().and_then(parse_u32)); + match (n, d, it.next()) { + (Some(n), Some(d), None) if n > 0 && d > 0 => rate = Some((n, d)), + _ => return refuse(SourceReason::Y4mHeader), + } + } + b'I' if v != b"p" => return refuse(SourceReason::Y4mInterlaced), + b'C' => { + chroma = match v { + b"420jpeg" | b"420paldv" | b"420mpeg2" | b"420" => Chroma::C420, + b"444" => Chroma::C444, + _ => return refuse(SourceReason::Y4mColorspace), + } + } + _ => {} + } + } + let (w, h) = match (w, h) { + (Some(w), Some(h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_PIXELS => (w, h), + _ => return refuse(SourceReason::Y4mHeader), + }; + Ok(Y4mReader { r, header: Header { w, h, rate, chroma }, buf: Vec::new() }) + } + + /// The bytes of one frame's planes. + pub fn frame_bytes(&self) -> usize { + let (w, h) = (self.header.w as usize, self.header.h as usize); + match self.header.chroma { + Chroma::C444 => 3 * w * h, + Chroma::C420 => w * h + 2 * w.div_ceil(2) * h.div_ceil(2), + } + } + + /// The next frame as RGB8, `None` at the end of the stream. + pub fn next_frame(&mut self) -> Option> { + self.read_frame().transpose() + } + + fn read_frame(&mut self) -> Result, SourceError> { + let Some(l) = line(&mut self.r, SourceReason::Y4mTruncated)? else { + return Ok(None); + }; + let mut parts = l.split(|&b| b == b' '); + if parts.next() != Some(&b"FRAME"[..]) { + return refuse(SourceReason::Y4mTruncated); + } + for p in parts.filter(|p| !p.is_empty()) { + match p[0] { + b'W' | b'H' | b'C' => return refuse(SourceReason::Y4mSizeChange), + b'I' if &p[1..] != b"p" => return refuse(SourceReason::Y4mInterlaced), + _ => {} + } + } + let n = self.frame_bytes(); + self.buf.resize(n, 0); + self.r.read_exact(&mut self.buf).map_err(|e| match e.kind() { + std::io::ErrorKind::UnexpectedEof => SourceError::Refused(SourceReason::Y4mTruncated), + _ => SourceError::Io(e), + })?; + Ok(Some(to_rgb(&self.header, &self.buf))) + } +} + +/// One frame's planes (Y, then Cb, then Cr) as RGB8, BT.709 limited range. +pub fn to_rgb(hd: &Header, planes: &[u8]) -> Frame { + let (w, h) = (hd.w as usize, hd.h as usize); + let (cw, ch) = match hd.chroma { + Chroma::C444 => (w, h), + Chroma::C420 => (w.div_ceil(2), h.div_ceil(2)), + }; + let (yp, rest) = planes.split_at(w * h); + let (up, vp) = rest.split_at(cw * ch); + let mut rgb = Vec::with_capacity(3 * w * h); + for y in 0..h { + for x in 0..w { + let ci = match hd.chroma { + Chroma::C444 => y * w + x, + Chroma::C420 => (y >> 1) * cw + (x >> 1), + }; + let (r, g, b) = bt709(yp[y * w + x], up[ci], vp[ci]); + rgb.extend_from_slice(&[r, g, b]); + } + } + Frame { w: hd.w, h: hd.h, rgb } +} + +/// BT.709 limited range, one pixel. +pub fn bt709(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) { + let (y, cb, cr) = (y as i32 - 16, cb as i32 - 128, cr as i32 - 128); + let clip = |v: i32| v.clamp(0, 255) as u8; + ( + clip((298 * y + 459 * cr + 128) >> 8), + clip((298 * y - 55 * cb - 136 * cr + 128) >> 8), + clip((298 * y + 541 * cb + 128) >> 8), + ) +} + +/// Every frame of a Y4M stream held in memory, with its header. +pub fn read_all(bytes: &[u8]) -> Result<(Header, Vec), SourceError> { + let mut r = Y4mReader::new(bytes)?; + let mut frames = Vec::new(); + while let Some(f) = r.next_frame() { + frames.push(f?); + } + Ok((r.header, frames)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn code(b: &[u8]) -> &'static str { + read_all(b).map(|_| "ok").unwrap_or_else(|e| e.code()) + } + + #[test] + fn refusals_are_named() { + let frame = [16u8; 6]; + let ok = [&b"YUV4MPEG2 W2 H2 F25:1 C420jpeg\nFRAME\n"[..], &frame].concat(); + assert_eq!(code(&ok), "ok"); + assert_eq!(code(b"YUV4MPEG W2 H2\n"), "y4m-header"); + assert_eq!(code(b"YUV4MPEG2 W2\n"), "y4m-header"); + assert_eq!(code(b"YUV4MPEG2 W2 H2 It\n"), "y4m-interlaced"); + assert_eq!(code(b"YUV4MPEG2 W2 H2 C422\n"), "y4m-colorspace"); + assert_eq!(code(b"YUV4MPEG2 W2 H2 C420p10\n"), "y4m-colorspace"); + assert_eq!(code(&[&b"YUV4MPEG2 W2 H2\nFRAME W4\n"[..], &frame].concat()), "y4m-size-change"); + assert_eq!(code(&[&b"YUV4MPEG2 W2 H2\nFRAME Ib\n"[..], &frame].concat()), "y4m-interlaced"); + assert_eq!(code(&[&b"YUV4MPEG2 W2 H2\nFRAME\n"[..], &frame[..5]].concat()), "y4m-truncated"); + assert_eq!(code(b"YUV4MPEG2 W2 H2\nFRAMEX"), "y4m-truncated"); + } + + #[test] + fn bt709_limited_range_endpoints() { + assert_eq!(bt709(16, 128, 128), (0, 0, 0)); + assert_eq!(bt709(235, 128, 128), (255, 255, 255)); + assert_eq!(bt709(126, 128, 128), (128, 128, 128)); + // Limited-range red, worked by hand: R = 65542 >> 8 clipped, G = 332 >> 8, + // B = 68 >> 8. + assert_eq!(bt709(63, 102, 240), (255, 1, 0)); + } +} diff --git a/wai/wai-pack/tests/conformance.rs b/wai/wai-pack/tests/conformance.rs new file mode 100644 index 000000000..1610b2808 --- /dev/null +++ b/wai/wai-pack/tests/conformance.rs @@ -0,0 +1,12 @@ +//! The packaging corpus (`wai/pack-conformance`) holds: every expected file is what +//! this crate computes from the corpus's inputs, and every generated input is what the +//! generator writes. + +use std::path::Path; + +#[test] +fn the_corpus_holds() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../pack-conformance"); + let m = wai_pack::conformance::check(&dir).expect("the corpus reads"); + assert!(m.is_empty(), "{m:#?}"); +} diff --git a/wai/wai-pack/tests/scenecut.rs b/wai/wai-pack/tests/scenecut.rs new file mode 100644 index 000000000..39695c0b8 --- /dev/null +++ b/wai/wai-pack/tests/scenecut.rs @@ -0,0 +1,60 @@ +//! `wai.pack.scenecut/1` on the corpus fixtures, stated as the cuts a reader can check +//! against the fixture's definition (`src/fixture.rs`), beside the corpus's pinned +//! vectors. + +use wai_pack::classify::classify; +use wai_pack::policy::Policy; +use wai_pack::scenecut::{segment, SegParams, FLAG_FORCED, FLAG_SCENE_CUT}; +use wai_pack::{fixture, source::y4m}; + +const POLICY: &str = include_str!("../../pack-conformance/scenecut/policy.toml"); + +fn run(name: &str, lookahead: u64) -> Vec<(u64, u8, &'static str)> { + let p = Policy::parse(POLICY).unwrap(); + let mut sp = SegParams::from_policy(&p.segmentation, p.component("v").unwrap().timebase); + sp.lookahead = lookahead; + let (hd, frames) = y4m::read_all(&fixture::generate(name).unwrap()).unwrap(); + let (pieces, _) = segment(sp, hd.w, hd.h, frames.iter().map(|f| &f.rgb)); + pieces.iter().map(|x| (x.start, x.flags, classify(&x.stats, &p.classify).label())).collect() +} + +fn starts(v: &[(u64, u8, &str)]) -> Vec { + v.iter().map(|x| x.0).collect() +} + +/// cuts-01: hard cuts at 60, 140 and 230, and at every switch between E and F (260, +/// 290, 320), each a return after more than the lookahead. The flash at 90..96 lasts +/// exactly the lookahead: neither its first frame (the return clause) nor the frame +/// that returns to B (the revisit clause) is a cut. The fade from C to D (160..200) +/// is not cut. +#[test] +fn cuts_01_cuts_where_its_shots_change() { + let got = run("cuts-01", 6); + assert_eq!(starts(&got), vec![0, 60, 140, 230, 260, 290, 320]); + assert!(got[1..].iter().all(|x| x.1 == FLAG_SCENE_CUT)); +} + +/// With a lookahead one frame shorter than the flash, its first frame is a cut: live +/// and on-demand packing agree only at equal lookahead. +#[test] +fn a_shorter_lookahead_cuts_the_flash() { + let got = starts(&run("cuts-01", 5)); + assert!(got.contains(&90), "{got:?}"); + assert!(!starts(&run("cuts-01", 6)).contains(&90)); +} + +/// kinds-01: one shot of each kind, then a static shot longer than `max` (100 frames +/// at 25 per second), forced at 260. +#[test] +fn kinds_01_classifies_each_shot() { + let got = run("kinds-01", 6); + let want = [ + (0, 0, "static"), + (40, FLAG_SCENE_CUT, "synthetic"), + (80, FLAG_SCENE_CUT, "motion_high"), + (120, FLAG_SCENE_CUT, "natural"), + (160, FLAG_SCENE_CUT, "static"), + (260, FLAG_FORCED, "static"), + ]; + assert_eq!(got, want); +} -- 2.54.0 (Apple Git-157) From 9a77c85e7732dfbed05887db79b7c44cc4cf1f46 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:34:30 -0400 Subject: [PATCH 03/24] wai-pack: equal policy spellings are checked on the digests computed now The corpus check compared the committed digests of each equal pair, which an implementation that had diverged would still match. Co-Authored-By: Claude Opus 5.5 --- wai/wai-pack/src/conformance.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wai/wai-pack/src/conformance.rs b/wai/wai-pack/src/conformance.rs index 73ba1b06a..ef1b800b5 100644 --- a/wai/wai-pack/src/conformance.rs +++ b/wai/wai-pack/src/conformance.rs @@ -290,8 +290,10 @@ fn same(what: &str, want: &Value, got: &Value, out: &mut Vec) { /// Generated inputs are compared byte for byte with what [`regen`] would write. pub fn check(dir: &Path) -> std::io::Result> { let mut out = Vec::new(); - same("policy/expected.json", &read_json(&dir.join("policy/expected.json"))?, &policy_expected(&dir.join("policy"))?, &mut out); - let pol = read_json(&dir.join("policy/expected.json"))?; + // The equal pairs are checked on the digests this implementation computes, not on + // the committed ones, which an implementation that diverged would still match. + let pol = policy_expected(&dir.join("policy"))?; + same("policy/expected.json", &read_json(&dir.join("policy/expected.json"))?, &pol, &mut out); let digest_of = |f: &str| { pol["cases"].as_array().and_then(|c| c.iter().find(|x| x["file"] == f && x.get("overrides").is_none())).map(|x| x["digest"].clone()) }; -- 2.54.0 (Apple Git-157) From 7c1a6dde73a5eecba8e144caa51a9010b1803ec3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:36:04 -0400 Subject: [PATCH 04/24] pack-conformance: the spelling pair carries a non-ASCII character and a tab So the canonical JSON's string form (RFC 8785: no escape for a non-ASCII character, the short escape for a tab) and TOML's unicode escapes are part of what the two implementations must agree on. Co-Authored-By: Claude Opus 5.5 --- wai/pack-conformance/policy/eq-spelling-a.toml | 2 +- wai/pack-conformance/policy/eq-spelling-b.toml | 2 +- wai/pack-conformance/policy/expected.json | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/wai/pack-conformance/policy/eq-spelling-a.toml b/wai/pack-conformance/policy/eq-spelling-a.toml index 0f54b0571..fa71200f9 100644 --- a/wai/pack-conformance/policy/eq-spelling-a.toml +++ b/wai/pack-conformance/policy/eq-spelling-a.toml @@ -1,6 +1,6 @@ # Standard tables, one member per line. schema = "wai.pack.policy/1" -asset_id = "spelling" +asset_id = "spélling\tid" lead = "v" [[component]] diff --git a/wai/pack-conformance/policy/eq-spelling-b.toml b/wai/pack-conformance/policy/eq-spelling-b.toml index 01ac1d0e9..7a88abe8a 100644 --- a/wai/pack-conformance/policy/eq-spelling-b.toml +++ b/wai/pack-conformance/policy/eq-spelling-b.toml @@ -2,7 +2,7 @@ # another order, literal strings, a hexadecimal and an underscored integer, comments. lead = 'v' # the lead component schema = "wai.pack.policy/1" -asset_id = "spel\u006cing" +asset_id = "sp\u00e9l\u006cing\u0009id" segmentation.lookahead_frames = 0x4 segmentation.target_ms = 3_000 diff --git a/wai/pack-conformance/policy/expected.json b/wai/pack-conformance/policy/expected.json index 28210d924..2678a4a5b 100644 --- a/wai/pack-conformance/policy/expected.json +++ b/wai/pack-conformance/policy/expected.json @@ -12,13 +12,13 @@ }, { "file": "eq-spelling-a.toml", - "canonical": "{\"asset_id\":\"spelling\",\"class\":[{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":1}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":8,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[12,6],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":4},\"refines\":\"b0\",\"role\":\"refinement\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":4,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":3000}}", - "digest": "d6bc57dde125d97a04eb8f18007bae4072d5a392fb3e60de18cb7d6e235aa2f8" + "canonical": "{\"asset_id\":\"spélling\\tid\",\"class\":[{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":1}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":8,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[12,6],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":4},\"refines\":\"b0\",\"role\":\"refinement\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":4,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":3000}}", + "digest": "23bbf95c100bf114061d7986b113fac020f46b9edd95b53bd5dd99b27aae11c0" }, { "file": "eq-spelling-b.toml", - "canonical": "{\"asset_id\":\"spelling\",\"class\":[{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":1}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":8,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[12,6],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":4},\"refines\":\"b0\",\"role\":\"refinement\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":4,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":3000}}", - "digest": "d6bc57dde125d97a04eb8f18007bae4072d5a392fb3e60de18cb7d6e235aa2f8" + "canonical": "{\"asset_id\":\"spélling\\tid\",\"class\":[{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":1}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"timebase\":{\"den\":1,\"num\":25}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":8,\"max_sse_px_milli\":0,\"mv_precision\":2,\"q_candidates\":[12,6],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":4},\"refines\":\"b0\",\"role\":\"refinement\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,0]},\"moment\":[],\"royalty_free_only\":false,\"rule\":[],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":4,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":3000}}", + "digest": "23bbf95c100bf114061d7986b113fac020f46b9edd95b53bd5dd99b27aae11c0" }, { "file": "example.toml", -- 2.54.0 (Apple Git-157) From 350f5240939bc24837ff381539ba88a9848503a5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:36:53 -0400 Subject: [PATCH 05/24] wai-pack: tests that a static piece outranks a synthetic one, and that 16 bits is required Co-Authored-By: Claude Opus 5.5 --- wai/wai-pack/src/classify.rs | 2 ++ wai/wai-pack/src/source/wav.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/wai/wai-pack/src/classify.rs b/wai/wai-pack/src/classify.rs index 04a06eb3b..42bb21f2f 100644 --- a/wai/wai-pack/src/classify.rs +++ b/wai/wai-pack/src/classify.rs @@ -68,6 +68,8 @@ mod tests { fn precedence_and_thresholds() { let st = |abs_diff, changed, occupancy| PieceStats { abs_diff, changed, compared: 1000, occupancy }; assert_eq!(classify(&st(300, 5, 40), &P), PieceKind::Static); + // Static and synthetic both hold: static comes first. + assert_eq!(classify(&st(0, 0, 3), &P), PieceKind::Static); assert_eq!(classify(&st(301, 5, 40), &P), PieceKind::Natural); assert_eq!(classify(&st(300, 6, 12), &P), PieceKind::Synthetic); assert_eq!(classify(&st(9000, 900, 12), &P), PieceKind::Synthetic); diff --git a/wai/wai-pack/src/source/wav.rs b/wai/wai-pack/src/source/wav.rs index 0f2693e1b..966fae97f 100644 --- a/wai/wai-pack/src/source/wav.rs +++ b/wai/wai-pack/src/source/wav.rs @@ -144,6 +144,11 @@ mod tests { assert_eq!(code(&wav(3, 1, 32, &[0; 4])), "wav-format"); assert_eq!(code(&wav(1, 1, 8, &[0; 4])), "wav-format"); assert_eq!(code(&wav(1, 1, 24, &[0; 6])), "wav-format"); + // 8-bit samples under a block alignment of 2 (a header that contradicts + // itself): the sample size alone refuses it. + let mut odd = wav(1, 1, 8, &[0; 4]); + odd[32] = 2; + assert_eq!(code(&odd), "wav-format"); assert_eq!(code(b"RIFX\0\0\0\0WAVE"), "wav-format"); let mut t = wav(1, 1, 16, &[0; 4]); t.truncate(t.len() - 1); -- 2.54.0 (Apple Git-157) From 37dd62b4cf0cea353d416b10d4a87002b90965d0 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:42:09 -0400 Subject: [PATCH 06/24] wai: the packaging extension's policy and pieces, and its CI extensions/packaging.md, first sections: the policy schema wai.pack.policy/1 with every member's default and range, the order a policy is read in and the name of every refusal, the policy digest over the effective policy's RFC 8785 form; closed pieces, the plan digest, wai.pack.scenecut/1 with its lookahead clauses, the unit conversions, alignment to the lead component, classification, and the Y4M and WAV sources. Sections 5 to 11 are reserved for the stages that write objects. .github/workflows/wai-pack.yml: on each architecture, clippy with float arithmetic denied, the tests in release with a floor per target, and the corpus recomputed; the Python verifier; and the dependency licences against an allow-list. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 131 ++++++++++++++ wai/CHANGELOG.md | 21 +++ wai/extensions/packaging.md | 266 ++++++++++++++++++++++++++++ wai/pack-conformance/.gitattributes | 2 + wai/pack-conformance/README.md | 51 ++++++ 5 files changed, 471 insertions(+) create mode 100644 .github/workflows/wai-pack.yml create mode 100644 wai/extensions/packaging.md create mode 100644 wai/pack-conformance/.gitattributes create mode 100644 wai/pack-conformance/README.md diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml new file mode 100644 index 000000000..b99163b0b --- /dev/null +++ b/.github/workflows/wai-pack.yml @@ -0,0 +1,131 @@ +name: wai-pack + +# The reference packager of WAI's packaging extension (wai/extensions/packaging.md) and +# its conformance corpus (wai/pack-conformance). Three jobs: +# +# - legs: on each architecture of the fleet, clippy with float arithmetic denied (no +# float decides anything in a package), the tests in release with a floor on how many +# each target lists, and the corpus recomputed by the crate. +# - independent: the corpus recomputed by its second implementation, written in Python +# from the extension text and sharing no code with wai-pack. +# - licences: every crate in wai-pack's dependency graph is under a licence deny.toml +# allows; a source-available licence fails it. +# +# Push filters match the push's delta, so every path a build or a verifier reads is +# listed: the crate, the wai crate it builds on, the corpus, the BLAKE3 and integer +# payload verifiers pack_verify.py imports, and this file. + +on: + pull_request: + paths: + - 'wai/wai-pack/**' + - 'wai/wai-rs/**' + - 'wai/pack-conformance/**' + - 'wai/claim-conformance/_blake3.py' + - 'wai/int-payload-conformance/int_payload_verify.py' + - '.github/workflows/wai-pack.yml' + push: + branches: [main] + paths: + - 'wai/wai-pack/**' + - 'wai/wai-rs/**' + - 'wai/pack-conformance/**' + - 'wai/claim-conformance/_blake3.py' + - 'wai/int-payload-conformance/int_payload_verify.py' + - '.github/workflows/wai-pack.yml' + +jobs: + legs: + name: wai-pack · ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Self-hosted fleet labels: x86_64 by linux, arm64 by linux-arm64 and macos. + # A Windows leg waits for its runner (see wai-byte-equality.yml). + os: [linux, linux-arm64, macos] + steps: + - uses: actions/checkout@v5 + + # The docker runners ship no Rust, so rustup is bootstrapped; the macOS host has + # it, but launchd's PATH omits ~/.cargo/bin. + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + rustup component add --toolchain 1.98.0 clippy + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # clippy::float_arithmetic is denied for the library and the CLI (Cargo.toml and + # the crate attributes); -D warnings makes every other lint fail the step too. + - name: clippy, float arithmetic denied + working-directory: wai/wai-pack + run: cargo clippy --locked --all-targets -- -D warnings + + - name: Tests (release) + working-directory: wai/wai-pack + run: cargo test --release --locked + + # A target that lost its tests (a module dropped, a cfg that compiles them out) + # would still report ok. Each target must list at least as many tests as it had + # when the floor was set; raise a floor when tests are added. + - name: Test-count floor per target + shell: bash + working-directory: wai/wai-pack + run: | + set -euo pipefail + fail=0 + while read -r target floor; do + n=$(cargo test --release --locked $target -- --list 2>/dev/null | grep -c ': test$' || true) + echo "$target: $n tests (floor $floor)" + [ "$n" -ge "$floor" ] || { echo "::error::$target lists $n tests, under its floor of $floor"; fail=1; } + done <<'EOF' + --lib 13 + --test=conformance 1 + --test=scenecut 3 + EOF + exit $fail + + - name: The corpus, recomputed by wai-pack + working-directory: wai/wai-pack + run: cargo run --release --locked --bin wai-pack -- conformance check ../pack-conformance + + independent: + name: wai-pack · independent verifier (Python) + runs-on: linux + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + # Standard library only (tomllib, hashlib); BLAKE3 comes from the shared pure-Python + # module beside the claim corpus. + - name: pack_verify.py + working-directory: wai + shell: bash + run: python3 pack-conformance/pack_verify.py pack-conformance + + licences: + name: wai-pack · dependency licences + runs-on: linux + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Install cargo-deny + shell: bash + run: cargo install --locked cargo-deny --version 0.19.8 + - name: cargo deny check licenses + working-directory: wai/wai-pack + run: cargo deny --locked check licenses diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index c7bea8e52..a547eb42e 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,27 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Packaging: a declared policy, its digest, and pieces cut at scene changes + +- **The extension** [packaging](extensions/packaging.md), first sections: a package is + made under one TOML policy, `wai.pack.policy/1` (§3), read in a fixed order with + every refusal named: value kinds a canonical JSON cannot carry exactly (floats, + dates, negatives, integers past `2^53 − 1`), then the schema, every default written + in, then the constraints across members (ladders and their refinement chains, + classes, rules, deadlines against layer lags). The effective policy's digest is + BLAKE3 over its RFC 8785 form, so two spellings of one policy have one digest. +- **Pieces** (§4): `wai.pack.scenecut/1` cuts a video component at scene changes by + integer histogram distance, with a bounded lookahead whose return and revisit + clauses keep a short flash from cutting; a decision reads no frame past the + lookahead, so live and on-demand packing cut alike. Pieces are bounded by `min` and + `max`, aligned across components to the lead, and classified static, synthetic, + high-motion or natural. Y4M and WAV sources convert by stated integer rules. +- **`wai-pack`**, the reference packager, as far as a plan (`wai-pack policy`, + `wai-pack plan`), with float arithmetic denied for the whole crate, and the corpus + [`pack-conformance/`](pack-conformance/) with a second implementation in Python. +- **`wai::jcs`**: the canonical JSON form moves out of `claim_receipt` into a module + of its own, compiled under every feature set, unchanged in behaviour. + ### Staged delivery: refinement layers, base admission, and WAI2 refinement components - **The extension** [staged-delivery](extensions/staged-delivery.md): a stage set is a diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md new file mode 100644 index 000000000..f14da165f --- /dev/null +++ b/wai/extensions/packaging.md @@ -0,0 +1,266 @@ +# WAI Extension: Packaging + +> Status: Draft. A packager turns sources into WAI objects under a declared policy; +> this extension fixes what a verifier recomputes. This revision defines the policy +> and its digest (§3) and how a source is cut into pieces (§4). Reference impl: +> `wai-pack` — `policy`, `source`, `scenecut`, `classify`; the corpus +> [`pack-conformance/`](../pack-conformance/). Keywords **MUST**, **MUST NOT**, +> **SHOULD**, **MAY** are RFC 2119/8174. + +## 1. Scope + +A **packager** turns one or more sources into WAI objects, stage indexes, catalogs +and a package receipt, under a declared **policy**. This extension fixes what a +package contains and what a verifier recomputes. It fixes an encoder's choices only +where it names them. + +## 2. Terms + +- **Component**: one media stream of a package (for example video, or audio). +- **Piece**: a span of a component that decodes with no reference outside itself. +- **Stage**: one object of a piece in staged order: the base is layer 0, refinement + *k* is layer *k* ([staged-delivery](staged-delivery.md)). +- **Rung**: one entry of the policy's ladder: a capability a component's pieces are + coded in, with its role (base, refinement or floor) and parameters. +- **Class**: a delivery profile the policy names. +- **Moment**: a declared span of presentation time with tags. +- **Floor**: a rendition in a standard codec, for sinks that lack the primary. +- **Timebase** of a component: `num / den` units per second. For video one unit is + one frame; for audio, one sample. + +## 3. Policy + +**3.1** A package is made under exactly one policy, a TOML 1.0 document of schema +`wai.pack.policy/1`. + +**3.2** The **effective policy** is the policy with every default written in (§3.3) +and every override the packager applies (such as a command line forcing +`royalty_free_only`). Its JSON form is §3.3's members, with an optional member that +has no default omitted when absent, and arrays of tables in document order. Its +**policy digest** is + + BLAKE3("wai:pack-policy\x01" ‖ JCS(effective policy as JSON)) + +where JCS is RFC 8785. Two spellings of one policy (member order, inline or standard +tables, dotted keys, string or integer notation, comments, an omitted default or the +default written out) therefore have one digest; the order of an array of tables is +part of the policy. + +A packager **MUST** refuse a policy that is not TOML 1.0 (`policy-toml`), and, before +it reads any key, one holding anywhere a float (`policy-float`), a date or time +(`policy-datetime`), a negative integer (`policy-negative`), or an integer above +`2^53 − 1` (`policy-integer-range`), visiting members in byte order of their keys and +arrays in order. + +**3.3** *Schema.* Within each table, a key this section does not define is refused +first (`policy-unknown-key`, the first in byte order), then each member in the order +listed: a required member that is absent (`policy-missing-key`), a member of another +type (`policy-type`), a value outside its range or vocabulary (`policy-value`). An +absent table reads as empty. + +| Member | Type | Default | Range | +|---|---|---|---| +| `schema` | string | required | `wai.pack.policy/1` (else `policy-schema`) | +| `asset_id` | string | required | 1 to 255 bytes | +| `royalty_free_only` | boolean | `false` | | +| `lead` | string | required | 1 to 255 bytes | +| `component` | array of tables | required | | +| `component.id` | string | required | 1 to 255 bytes | +| `component.media` | string | required | `video`, `image`, `audio`, `world`, `feed`, `avatar`, `film` | +| `component.source` | string | absent | | +| `component.timebase` | table | required | members `num`, `den`, each 1 to `2^32 − 1`, required | +| `segmentation.target_ms` | integer | 2000 | | +| `segmentation.min_ms` | integer | 1000 | | +| `segmentation.max_ms` | integer | 4000 | | +| `segmentation.scene_cut` | string | `wai.pack.scenecut/1` | `wai.pack.scenecut/1` | +| `segmentation.cut_abs_permille` | integer | 300 | 1 to 1000 | +| `segmentation.cut_rel_permille` | integer | 3000 | 0 to 1000000 | +| `segmentation.cut_window` | integer | 8 | 1 to 64 | +| `segmentation.lookahead_frames` | integer | 6 | 0 to 64 | +| `segmentation.closed` | boolean | `true` | | +| `ladder` | array of tables | empty | | +| `ladder.id` | string | required | 1 to 255 bytes | +| `ladder.component` | string | required | 1 to 255 bytes | +| `ladder.capability` | string | required | | +| `ladder.role` | string | required | `base`, `refinement`, `floor` | +| `ladder.refines` | string | absent | | +| `ladder.params` | table | empty | by capability, below | +| `floor.supplied_listing` | string | absent | 64 lowercase hexadecimal digits | +| `floor.extra_keyframes` | boolean | `false` | | +| `live.layer_lag_ms` | array of integers | one 0 per layer | §3.4 | +| `class` | array of tables | empty | | +| `class.id` | string | required | 1 to 255 bytes | +| `class.max_layer` | integer | 0 | 0 to 255 | +| `class.max_kbps` | integer | absent | 1 to `2^32 − 1` | +| `class.floor_only` | boolean | `false` | | +| `moment` | array of tables | empty | | +| `moment.id` | string | required | 1 to 255 bytes | +| `moment.from_ms`, `moment.to_ms` | integer | required | `to_ms` above `from_ms` | +| `moment.tags` | array of strings | empty | | +| `rule` | array of tables | empty | | +| `rule.id` | string | required | 1 to 255 bytes | +| `rule.when.piece_kind` | array of strings | absent | `natural`, `static`, `synthetic`, `motion_high` | +| `rule.when.moment_tag` | array of strings | absent | | +| `rule.then.ladder` | array of strings | absent | | +| `rule.then.max_layer` | integer | absent | 0 to 255 | +| `rule.then.deadline_ms` | integer | absent | 1 to `2^32 − 1` | +| `defaults.deadline_ms` | integer | 1500 | 1 to `2^32 − 1` | +| `classify.static_permille` | integer | 5 | 0 to 1000 | +| `classify.static_mad_milli` | integer | 300 | | +| `classify.synthetic_bins` | integer | 12 | 0 to 64 | +| `classify.motion_high_milli` | integer | 9000 | | + +A rung's `params` are those of its capability; every other capability takes none. + +| Capability | Parameter | Default | Range | +|---|---|---|---| +| `wai.video.int_motion` | `block` | 16 | 4, 8, 16, 32 | +| | `search` | 7 | 0 to 32 | +| | `mv_precision` | 2 | 1, 2 | +| | `q_candidates` | `[8]` | a non-empty array, each 1 to 255 | +| | `max_sse_px_milli` | 0 | | +| `wai.video.int_refine` | `q` | required | 1 to 255 | +| `wai.video.av1`, `wai.video.av1.lossless` | `source` | `supplied` | `supplied`, `encode` | +| `wai.audio.int_layered` | `rate` | required | 1 to 384000 | +| `wai.audio.int_refine` | `to_rate`, `q` | required | 1 to 384000; 1 to 65535 | +| `wai.audio.flac` | `source` | `encode` | `encode`, `supplied` | + +**3.4** *Constraints.* A policy that passes §3.3 is checked in this order, and the +first that fails is the refusal: + +1. 1 to 8 components (`policy-too-many-components`); at most 64 rungs + (`policy-too-many-rungs`), 32 classes (`policy-too-many-classes`), 32 rules + (`policy-too-many-rules`) and 32 moments (`policy-too-many-moments`). +2. Ids unique among components, rungs, classes, moments and rules + (`policy-duplicate-id`). +3. `lead` names a component (`policy-lead`). +4. `segmentation.closed` is true: every piece is closed, §4.1 (`policy-open-gop`). +5. `1 ≤ min_ms ≤ target_ms ≤ max_ms` (`policy-piece-bounds`). +6. Every rung names a component (`policy-ladder-component`). +7. A rung carries `refines` exactly when its role is `refinement` + (`policy-refines-role`); `refines` names a rung (`policy-dangling-refines`); no + chain of `refines` returns to a rung it passed (`policy-refines-cycle`); every + chain stays in its component and ends at a base (`policy-refines-base`); no rung is + refined twice (`policy-refines-branch`). +8. Every component has a base rung (`policy-no-base`). +9. A rung's **layer** is 0 for a base or a floor, and one more than the rung it + refines for a refinement; the policy has one more layer than its highest rung + layer. No class's and no rule's `max_layer` exceeds the highest + (`policy-class-layer`). +10. A rule's `then.ladder` names known rungs, all of one component, at least one of + them a base (`policy-rule-ladder`). +11. `live.layer_lag_ms` has one entry per layer, the first 0, the entries + non-decreasing (`policy-layer-lag`). +12. The deadline values (`defaults.deadline_ms` and every rule's `then.deadline_ms`) + number at most 4 (`policy-too-many-deadlines`), and for every refinement layer + *k* and every deadline value *d*, `d − layer_lag_ms[k] ≥ 1` + (`policy-deadline-unreachable`). + +Rules fold in document order: every matching rule applies, and a later `then` member +overrides an earlier one. + +**3.5** A package receipt carries the policy digest. A verifier holding the effective +policy recomputes it; it cannot check that the packager followed the policy, except +by packing again. + +## 4. Pieces + +**4.1** Every piece **MUST** be closed: its first frame, sample or tick is coded with +no reference outside the piece, and no later object of the package references into +it. + +**4.2** Piece boundaries are a pure function of the source frames and the effective +policy. Where the policy names `wai.pack.scenecut/1`, the boundaries **MUST** be those +§4.3 computes. The list of piece starts, in timebase units, is the **plan**, and its +digest is + + BLAKE3("wai:pack-plan\x01" ‖ u64 LE n ‖ n × u64 LE start) + +**4.3** `wai.pack.scenecut/1`. All arithmetic is integer; `>>` is a shift and every +division rounds down. For each RGB8 frame *f* of `w × h` pixels: + +1. Luma `Y = (77R + 150G + 29B + 128) >> 8`, and the histogram `H_f` of 64 bins, + `Y >> 2`. +2. `D(a, b) = (Σ_i |H_a[i] − H_b[i]|) · 1000 / (2wh)`: the share of pixels, in + thousandths, whose bin differs. The score of frame *f* ≥ 1 is `s_f = D(f, f − 1)`. +3. The current piece starts at frame *p*, and `len = f − p`. Its window `W_f` is the + scores `s_g` for `p < g < f`, the last `cut_window` of them; `m_f` is their lower + median (sorted ascending, the element at index `(|W_f| − 1) / 2`). +4. **Hard cut** at *f*: `len ≥ min`, `s_f ≥ cut_abs`, and either `|W_f| < 3` or + `s_f · 1000 ≥ cut_rel · m_f`. +5. **Soft cut** at *f*: `len ≥ target` and `2 · s_f ≥ cut_abs`. +6. **Return clause.** A cut at *f* is rejected when `D(f + k, f − 1) < cut_abs` for + some *k* in `1..=min(L, last − f)`: the content came back within *L* frames, so *f* + starts a flash. +7. **Revisit clause.** A cut at *f* is rejected when `D(f, f − 1 − j) < cut_abs` for + some *j* in `1..=min(L, f − 1)`: *f* returns to content seen within *L* frames, so it + ends a flash. Frames before *p* count. +8. A hard or soft cut that clauses 6 and 7 do not reject starts a piece at *f*. + Otherwise, when `len ≥ max`, a piece starts at *f*. + +Frame 0 starts the first piece. *L* is `lookahead_frames`, *last* the index of the +source's last frame, `cut_abs` and `cut_rel` the policy's permille values, and `min`, +`target` and `max` the policy's milliseconds on the component's timebase (§4.4). A +decision about frame *f* reads no frame after `f + L`, so a live segmenter decides it +once frame `f + L` has arrived, and a live and an on-demand segmenter with equal *L* +produce equal boundaries. A piece that follows a cut is flagged a scene cut; one +started at `max`, forced. + +**4.4** A piece's duration is an integer number of timebase units, and lies between +`min` and `max` except the last piece of a source, which may be shorter than `min`. +A duration in milliseconds converts to timebase units as +`floor(ms · num / (1000 · den))`; a result of 0 becomes 1, and one above `2^64 − 1` +becomes `2^64 − 1`. + +**4.5** *Components.* Every component of a package shares one presentation timeline +that starts at 0. The policy names one **lead** component; every other component cuts +its pieces at the lead's boundary instants, each converted to its own timebase as +`floor(start_lead · num_c · den_lead / (den_c · num_lead))` (above `2^64 − 1`, +`2^64 − 1`). A sink presents piece *p* of every component from that start. + +**4.6** *Classification.* Each video piece has a kind, which rules match +(`rule.when.piece_kind`). Over the piece's consecutive frame pairs, with `Y` the luma +of §4.3: + +- `mad_milli = Σ |Y_f − Y_{f−1}| · 1000 / (wh · (n − 1))`, and 0 when the piece has + one frame (*n* = 1); +- `changed_permille` = the pixels whose luma differs, `· 1000 / (wh · (n − 1))`, and 0 + when *n* = 1; +- `occupancy` = the non-empty histogram bins of the piece's first frame. + +The kind is the first that holds of: **static** (`mad_milli ≤ static_mad_milli` and +`changed_permille ≤ static_permille`); **synthetic** (`occupancy ≤ synthetic_bins`); +**motion_high** (`mad_milli ≥ motion_high_milli`); **natural**. + +**4.7** *Sources.* + +- **Y4M**: 8-bit 4:2:0 (`C420jpeg`, `C420paldv`, `C420mpeg2`, `C420`, or no `C` + parameter) or 4:4:4 (`C444`), progressive. A header that is not `YUV4MPEG2` with a + width and a height is refused `y4m-header`; an interlaced stream or frame, + `y4m-interlaced`; another colour space, `y4m-colorspace`; a `FRAME` line that sets + the width, height or colour space, `y4m-size-change`; a frame that ends early or a + line that is not `FRAME`, `y4m-truncated`. Each pixel converts to RGB8 by BT.709 + limited range, with 4:2:0 chroma taken at `(y >> 1, x >> 1)`: + - `R = clip((298(Y−16) + 459(Cr−128) + 128) >> 8)` + - `G = clip((298(Y−16) − 55(Cb−128) − 136(Cr−128) + 128) >> 8)` + - `B = clip((298(Y−16) + 541(Cb−128) + 128) >> 8)` + + where `>>` is the arithmetic shift and `clip` clamps to 0..=255. +- **WAV**: RIFF/WAVE with a PCM `fmt ` chunk (format tag 1) of 16-bit samples, block + alignment `2 · channels`. Anything else is refused `wav-format`; a chunk that ends + early, `wav-truncated`. + +## 5–11. Objects, stage index, floor, classes and delivery, receipt + +Reserved. This revision does not yet define a package's objects, its stage index, its +floor, its class plans and catalogs, its receipt, royalty-free packaging or re-pack +equivalence; they are specified with the packager stages that write them. + +## 12. Reasons + +| Reason | Where | +|---|---| +| `policy-toml`, `policy-float`, `policy-datetime`, `policy-negative`, `policy-integer-range` | §3.2 | +| `policy-unknown-key`, `policy-missing-key`, `policy-type`, `policy-value`, `policy-schema` | §3.3 | +| `policy-too-many-components`, `policy-too-many-rungs`, `policy-too-many-classes`, `policy-too-many-rules`, `policy-too-many-moments`, `policy-duplicate-id`, `policy-lead`, `policy-open-gop`, `policy-piece-bounds`, `policy-ladder-component`, `policy-refines-role`, `policy-dangling-refines`, `policy-refines-cycle`, `policy-refines-base`, `policy-refines-branch`, `policy-no-base`, `policy-class-layer`, `policy-rule-ladder`, `policy-layer-lag`, `policy-too-many-deadlines`, `policy-deadline-unreachable` | §3.4 | +| `y4m-header`, `y4m-interlaced`, `y4m-colorspace`, `y4m-size-change`, `y4m-truncated`, `wav-format`, `wav-truncated` | §4.7 | diff --git a/wai/pack-conformance/.gitattributes b/wai/pack-conformance/.gitattributes new file mode 100644 index 000000000..eff1df73a --- /dev/null +++ b/wai/pack-conformance/.gitattributes @@ -0,0 +1,2 @@ +# Hashed files: a line-ending conversion on checkout would change every digest. +* -text diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md new file mode 100644 index 000000000..abc83f9e9 --- /dev/null +++ b/wai/pack-conformance/README.md @@ -0,0 +1,51 @@ +# pack-conformance — the packaging corpus + +Vectors for [`extensions/packaging.md`](../extensions/packaging.md): the policy and its +digest (§3) and how a source is cut into pieces (§4). + +- `policy/` — policies and `expected.json`: each policy's effective canonical JSON and + digest (`example.toml` also with `royalty_free_only` overridden to false), and the + pairs `eq--a.toml` and `eq--b.toml`, two spellings of one policy that + must have one digest: every default omitted or written out; standard or inline + tables, dotted keys, member order, string escapes, integer notation and comments. +- `refusals/` — `policy/*.toml`, one fault each, and `source/*` (Y4M and WAV), and + `expected.json`: each one's reason (§12). The source files are written by the + generator. +- `conversions.json` and `conversions/` — milliseconds to a timebase (§4.4), a lead + start to a component's timebase (§4.5), and Y4M to RGB8 (§4.7) by the SHA-256 of each + frame, for 4:2:0 with odd dimensions and for 4:4:4. The Y4M files are written by the + generator. +- `scenecut/` — `policy.toml` and `expected.json`: for each fixture, its SHA-256, the + score of every frame, every piece (start, frames, flags, kind, and the statistics the + kind is read from) and the plan digest (§4.2–§4.6). + +The fixtures are not committed. `wai-pack fixture gen ` writes each from its +definition (`wai-pack/src/fixture.rs`), every byte an integer function of the frame +index and the pixel position, and the corpus pins its SHA-256: + +- `cuts-01` — hard cuts; a flash exactly as long as the lookahead, which is not cut; + a fade, which is not cut; and two shots cut against each other, each return a cut. +- `kinds-01` — one shot of each kind, then a static shot longer than the longest + piece, cut where `max` forces it. + +Files are compared as bytes; `.gitattributes` turns off line-ending conversion. + +**Generation.** + + cd wai/wai-pack + cargo run --release -- conformance regen ../pack-conformance + +**Verifiers.** + +- `wai-pack conformance check ../pack-conformance` recomputes every expected file and + every generated input and requires them to equal the committed ones, and requires + the digests of each equal pair to be equal. `cargo test` runs the same check, with + the scene cuts of each fixture stated as tests (`tests/scenecut.rs`). +- `pack_verify.py`, Python standard library only (TOML through `tomllib`, so 3.11 or + later) and sharing no code with wai-pack, written from the extension: its own policy + schema, constraints, canonical JSON and digest; its own Y4M and WAV readers; the + fixtures regenerated from their definition; and `wai.pack.scenecut/1` in its + on-demand form, every frame known, against the vectors of the streaming segmenter. + BLAKE3 comes from `../claim-conformance/_blake3.py`. + + python3 pack-conformance/pack_verify.py pack-conformance -- 2.54.0 (Apple Git-157) From f4bcb79e626e18c5ccb1795fe9cefaeafff6f320 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:56:05 -0400 Subject: [PATCH 07/24] wai: int_video_enc, the one WIV1 encoder; the zero-motion writer is its case src/int_video_enc.rs (feature video_int) codes RGB8 frames as a WIV1 keyframe-0 payload: integer block motion search over the decoder's own prediction from the previous frame as decoded (dy then dx ascending, least SAD, ties to the shorter vector, then the earlier one), whole- or half-pel; residuals quantized half away from zero in a closed loop; one residual table for the clip by the rule link 18's writer used. The caps, the motion-vector field's included, are checked before anything is allocated, and the SSE of the reconstruction is returned with it. int_payload::encode_wiv1_zero_motion keeps its signature and its bytes: it is now encode() at block max(h, w), search 0, whole-pel. The staged corpus, whose bases it writes, regenerates byte for byte. int_video::predict is public, so the encoder predicts with the decoder's own function. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/int_payload/mod.rs | 91 +------- wai/wai-rs/src/int_video.rs | 2 +- wai/wai-rs/src/int_video_enc.rs | 349 ++++++++++++++++++++++++++++++ wai/wai-rs/src/lib.rs | 6 + 4 files changed, 364 insertions(+), 84 deletions(-) create mode 100644 wai/wai-rs/src/int_video_enc.rs diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index ffd49ffb3..903f531fe 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -830,17 +830,6 @@ pub fn wis1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError // ───────────────────────── a reference keyframe-0 encoder ───────────────────────── -/// `n / d` rounded to the nearest integer, halves away from zero, for `d ≥ 1`: the -/// quantizer of [`encode_wiv1_zero_motion`] (the rounding of -/// `staged::predict::rdiv`, which this module cannot reach without `staged`). -#[cfg(feature = "video_int")] -fn round_div(n: i64, d: u64) -> i64 { - let m = n.unsigned_abs(); - let (q, r) = (m / d, m % d); - let mag = (q + (r >= d - r) as u64) as i64; - if n < 0 { -mag } else { mag } -} - /// Code `frames` (each `h × w` RGB8, rows top to bottom) as a `WIV1` keyframe-0 /// payload (`wai.video.int_motion`) with zero motion and one block per frame: frame 0 /// is predicted from grey (128), each later frame from the previous frame as decoded, @@ -850,78 +839,14 @@ fn round_div(n: i64, d: u64) -> i64 { /// it is coded through the bypass escape). Returns the payload and its decode, each /// frame as `decode_int` gives it. /// -/// A reference encoder for the format, not a rate-distortion encoder: it searches no -/// motion. It writes a closed group of pictures, since frame 0 refers to nothing. A -/// clip past the format's caps (`MAX_TENSOR` elements in a frame, `MAX_FRAMES` frames, -/// `MAX_CLIP` elements) is refused `too_large` before anything is allocated. +/// The zero-motion case of the crate's one `WIV1` encoder, [`crate::int_video_enc`]: +/// block `max(h, w)`, search radius 0, whole-pel. It writes a closed group of +/// pictures, since frame 0 refers to nothing. A clip past the format's caps +/// (`MAX_TENSOR` elements in a frame, `MAX_FRAMES` frames, `MAX_CLIP` elements) is +/// refused `too_large` before anything is allocated. #[cfg(feature = "video_int")] pub fn encode_wiv1_zero_motion(frames: &[Vec], h: u32, w: u32, q: u32) -> Result<(Vec, Vec>), IntPayloadError> { - let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); - if !(1..=wire::MAX_Q).contains(&q) { - return Err(field("q is outside 1..=65535")); - } - if frames.is_empty() || h == 0 || w == 0 { - return Err(field("a clip needs a frame of at least one pixel")); - } - // The format's caps, checked before anything is allocated: a frame of at most - // MAX_TENSOR elements, at most MAX_FRAMES frames and MAX_CLIP elements in the clip. - let too_large = |why: &str| IntPayloadError::new(Refusal::TooLarge, why.to_string()); - let len = (h as u64).checked_mul(w as u64).and_then(|p| p.checked_mul(3)).filter(|&l| l <= wire::MAX_TENSOR).ok_or_else(|| too_large("h × w × 3 exceeds the tensor cap"))?; - if frames.len() as u64 > wire::MAX_FRAMES { - return Err(too_large("more frames than the frame cap")); - } - if (frames.len() as u64).checked_mul(len).is_none_or(|c| c > wire::MAX_CLIP) { - return Err(too_large("the clip exceeds the clip cap")); - } - let n = frames.len() as u32; - if frames.iter().any(|f| f.len() as u64 != len) { - return Err(field("a frame is not h × w × 3 bytes")); - } - let mut recon: Vec> = Vec::with_capacity(frames.len()); - let mut rs: Vec = Vec::with_capacity(frames.len() * len as usize); - for (f, src) in frames.iter().enumerate() { - let mut cur = Vec::with_capacity(src.len()); - for (i, &t) in src.iter().enumerate() { - let pred = if f == 0 { 128 } else { recon[f - 1][i] as i64 }; - let r = round_div(t as i64 - pred, q as u64); - rs.push(r); - cur.push((pred + r * q as i64).clamp(0, 255) as u8); - } - recon.push(cur); - } - let lo = *rs.iter().min().expect("at least one sample"); - let hi = *rs.iter().max().expect("at least one sample"); - let width = ((hi - lo + 1) as u64).min(4095); - let mut hist = vec![0u64; width as usize]; - for &r in &rs { - if r >= lo && r < lo + width as i64 { - hist[(r - lo) as usize] += 1; - } - } - let cdf = crate::int_entropy::hist_to_cdf(&hist, 16).ok_or_else(|| field("no residual table fits"))?; - let mut e = crate::rans::RansEncoder::new(); - for &r in &rs { - if !e.encode(r as i32, &cdf, cdf.len(), lo as i32) { - return Err(field("a residual the stream cannot code")); - } - } - let stream = e.flush(); - let one = n == 1; - let v = wire::Wiv1 { - mv_precision: 1, - keyframe: 0, - n_frames: n, - h, - w, - block: if one { 1 } else { h.max(w) }, - q, - offset: lo as i32, - cdf, - mvs: vec![(0, 0); if one { 0 } else { n as usize - 1 }], - rans: &stream, - wih1: None, - }; - let out = wire::write_wiv1(&v); - wire::parse_wiv1(&out, Some(0))?; - Ok((out, recon)) + let p = crate::int_video_enc::EncParams { block: h.max(w).max(1), search: 0, mv_precision: 1, q }; + let e = crate::int_video_enc::encode(frames, h, w, &p)?; + Ok((e.wiv1, e.recon)) } diff --git a/wai/wai-rs/src/int_video.rs b/wai/wai-rs/src/int_video.rs index 92773bec7..7ab32eea0 100644 --- a/wai/wai-rs/src/int_video.rs +++ b/wai/wai-rs/src/int_video.rs @@ -251,7 +251,7 @@ impl VideoJson { /// sub-pixel motion (real footage) predicts well while the decode stays exact. #[inline] #[allow(clippy::too_many_arguments)] -fn predict(rf: &[u8], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64, h: usize, w: usize) -> i64 { +pub fn predict(rf: &[u8], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64, h: usize, w: usize) -> i64 { let p = |yy: i64, xx: i64| -> i64 { let cy = yy.clamp(0, h as i64 - 1) as usize; let cx = xx.clamp(0, w as i64 - 1) as usize; diff --git a/wai/wai-rs/src/int_video_enc.rs b/wai/wai-rs/src/int_video_enc.rs new file mode 100644 index 000000000..fec3f522b --- /dev/null +++ b/wai/wai-rs/src/int_video_enc.rs @@ -0,0 +1,349 @@ +//! The integer video encoder: RGB8 frames to a `WIV1` keyframe-0 payload +//! (`wai.video.int_motion`, `extensions/integer-payloads.md` §3), whose decode is +//! [`crate::int_video`]'s. It is the one encoder of the format in this crate: +//! `int_payload::encode_wiv1_zero_motion` is its zero-motion case. +//! +//! All integer, so a clip, its parameters and this module's revision fix the payload's +//! every byte: +//! +//! - **Motion search** (frames 1 on): blocks in raster order; for each, every vector +//! `(dy, dx)` with both components in `−R..=R`, `R = search · mv_precision`, `dy` +//! outer and `dx` inner, each ascending; its cost is the sum, over the block's pixels +//! inside the frame and the three channels, of `|source − prediction|`, the +//! prediction being the decoder's ([`crate::int_video::predict`]) from the previous +//! frame **as decoded**. The least cost wins; a tie goes to the smaller +//! `|dy| + |dx|`, then to the earlier vector. +//! - **Residual**: for each frame, each pixel in raster order, each of R, G, B, the +//! prediction (128 for frame 0) is subtracted from the source and quantized at step +//! `q`, rounding halves away from zero ([`quantize`]); the frame as decoded is +//! `clamp(prediction + r·q, 0, 255)`, and the next frame predicts from it, so the +//! encoder and every decoder hold the same reference (a closed loop). +//! - **Table**: one residual table for the clip ([`residual_table`]): the histogram of +//! the residuals from the least, at most 4,095 symbols wide, through +//! `int_entropy::hist_to_cdf` at precision 16; a residual past it is coded through +//! the bypass escape (`extensions/integer-payloads.md` §2). +//! +//! A clip of one frame is written with `block` 1 and `mv_precision` 1, as the format +//! requires. The caps (a frame, the clip, the frame count, the motion-vector field) are +//! checked before anything is allocated. + +use crate::int_payload::{wire, IntPayloadError, Refusal}; +use crate::int_video::predict; + +/// The most `search · mv_precision` this encoder searches: a vector component's range. +/// +/// cbindgen:ignore +pub const MAX_SEARCH_RANGE: u32 = 127; + +/// What the encoder is asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncParams { + /// The block edge in pixels, at least 1. + pub block: u32, + /// The search radius in whole pixels; 0 searches only the zero vector. + pub search: u32, + /// 1, whole-pel vectors; 2, half-pel. + pub mv_precision: u8, + /// The quantizer step, `1..=65535`. + pub q: u32, +} + +/// An encoded clip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Encoded { + /// The `WIV1` payload. + pub wiv1: Vec, + /// Each frame as every decoder reconstructs it. + pub recon: Vec>, + /// `Σ (source − recon)²` over every sample of every frame. + pub sse: u64, +} + +/// `r / q` rounded to the nearest integer, halves away from zero: `sign(r)·⌊(2|r| + +/// q) / 2q⌋`, for `q ≥ 1` (the rounding of `staged::predict::rdiv`). +pub fn quantize(r: i64, q: u32) -> i64 { + let (m, d) = (r.unsigned_abs(), q.max(1) as u64); + let (n, rem) = (m / d, m % d); + let mag = (n + (rem >= d - rem) as u64) as i64; + if r < 0 { -mag } else { mag } +} + +/// The clip's residual table and offset: the histogram of the residuals from the least +/// (`offset`), at most 4,095 symbols wide, fitted by `int_entropy::hist_to_cdf` at +/// precision 16. A residual past its span is coded through the bypass escape. +pub fn residual_table(rs: &[i64]) -> Result<(Vec, i32), IntPayloadError> { + let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); + let lo = *rs.iter().min().ok_or_else(|| field("no residual to fit a table to"))?; + let hi = *rs.iter().max().ok_or_else(|| field("no residual to fit a table to"))?; + let width = ((hi - lo + 1) as u64).min(4095); + let mut hist = vec![0u64; width as usize]; + for &r in rs { + if r >= lo && r < lo + width as i64 { + hist[(r - lo) as usize] += 1; + } + } + let cdf = crate::int_entropy::hist_to_cdf(&hist, 16).ok_or_else(|| field("no residual table fits"))?; + let offset = i32::try_from(lo).map_err(|_| field("a residual outside i32"))?; + Ok((cdf, offset)) +} + +/// The vector of one block: the least-cost candidate under the search order and +/// tie-break of the module documentation. +#[allow(clippy::too_many_arguments)] +fn search_block(src: &[u8], rf: &[u8], h: usize, w: usize, y0: usize, x0: usize, block: usize, range: i64, prec: i64) -> (i16, i16) { + let (y1, x1) = ((y0 + block).min(h), (x0 + block).min(w)); + let mut best: (u64, i64, i64, i64) = (u64::MAX, 0, 0, 0); // cost, |dy|+|dx|, dy, dx + for dy in -range..=range { + for dx in -range..=range { + let mut cost = 0u64; + 'px: for y in y0..y1 { + for x in x0..x1 { + for c in 0..3 { + let p = predict(rf, y, x, dy, dx, c, prec, h, w); + cost += (src[(y * w + x) * 3 + c] as i64 - p).unsigned_abs(); + } + // A candidate already past the best cannot win; stopping early + // changes no outcome. + if cost > best.0 { + break 'px; + } + } + } + let l1 = dy.abs() + dx.abs(); + if cost < best.0 || (cost == best.0 && l1 < best.1) { + best = (cost, l1, dy, dx); + } + } + } + (best.2 as i16, best.3 as i16) +} + +/// Encode `frames`, each `h × w` RGB8 (rows top to bottom), as a `WIV1` keyframe-0 +/// payload under `p`. Refuses `field_range` for a parameter outside its range (`q` +/// outside `1..=65535`, `block` 0, `mv_precision` other than 1 or 2, `search · +/// mv_precision` past [`MAX_SEARCH_RANGE`]), an empty clip, a zero dimension or a frame +/// that is not `h·w·3` bytes; `too_large` for a clip past the format's caps, before +/// anything is allocated. +pub fn encode(frames: &[Vec], h: u32, w: u32, p: &EncParams) -> Result { + let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); + if !(1..=wire::MAX_Q).contains(&p.q) { + return Err(field("q is outside 1..=65535")); + } + if p.block == 0 || !matches!(p.mv_precision, 1 | 2) || p.search.saturating_mul(p.mv_precision as u32) > MAX_SEARCH_RANGE { + return Err(field("block is 0, mv_precision is neither 1 nor 2, or the search range is past 127")); + } + if frames.is_empty() || h == 0 || w == 0 { + return Err(field("a clip needs a frame of at least one pixel")); + } + // The format's caps, checked before anything is allocated: a frame of at most + // MAX_TENSOR elements, at most MAX_FRAMES frames and MAX_CLIP elements in the clip, + // and a motion-vector field of at most MAX_TENSOR elements. + let too_large = |why: &str| IntPayloadError::new(Refusal::TooLarge, why.to_string()); + let len = (h as u64) + .checked_mul(w as u64) + .and_then(|p| p.checked_mul(3)) + .filter(|&l| l <= wire::MAX_TENSOR) + .ok_or_else(|| too_large("h × w × 3 exceeds the tensor cap"))?; + if frames.len() as u64 > wire::MAX_FRAMES { + return Err(too_large("more frames than the frame cap")); + } + if (frames.len() as u64).checked_mul(len).is_none_or(|c| c > wire::MAX_CLIP) { + return Err(too_large("the clip exceeds the clip cap")); + } + let n = frames.len(); + let one = n == 1; + let (block, prec) = if one { (1, 1) } else { (p.block, p.mv_precision) }; + let (nby, nbx) = ((h as u64).div_ceil(block as u64), (w as u64).div_ceil(block as u64)); + let nb = nby * nbx; + if nb.checked_mul(2 * (n as u64 - 1)).is_none_or(|m| m > wire::MAX_TENSOR) { + return Err(too_large("the motion vectors exceed the tensor cap")); + } + if frames.iter().any(|f| f.len() as u64 != len) { + return Err(field("a frame is not h × w × 3 bytes")); + } + let (h, w, block, nbx) = (h as usize, w as usize, block as usize, nbx as usize); + let range = if one { 0 } else { (p.search * prec as u32) as i64 }; + let mut recon: Vec> = Vec::with_capacity(n); + let mut mvs: Vec<(i16, i16)> = Vec::with_capacity(nb as usize * (n - 1)); + let mut rs: Vec = Vec::with_capacity(n * len as usize); + let mut sse = 0u64; + for (f, src) in frames.iter().enumerate() { + let start = mvs.len(); + if f > 0 { + let rf = &recon[f - 1]; + for by in 0..nby as usize { + for bx in 0..nbx { + mvs.push(search_block(src, rf, h, w, by * block, bx * block, block, range, prec as i64)); + } + } + } + let mut cur = Vec::with_capacity(src.len()); + for y in 0..h { + for x in 0..w { + let (dy, dx) = if f == 0 { (0, 0) } else { mvs[start + (y / block) * nbx + x / block] }; + for c in 0..3 { + let pred = match f { + 0 => 128, + _ => predict(&recon[f - 1], y, x, dy as i64, dx as i64, c, prec as i64, h, w), + }; + let t = src[(y * w + x) * 3 + c] as i64; + let r = quantize(t - pred, p.q); + rs.push(r); + let v = (pred + r * p.q as i64).clamp(0, 255); + sse += ((t - v) * (t - v)) as u64; + cur.push(v as u8); + } + } + } + recon.push(cur); + } + let (cdf, offset) = residual_table(&rs)?; + let mut e = crate::rans::RansEncoder::new(); + for &r in &rs { + if !e.encode(r as i32, &cdf, cdf.len(), offset) { + return Err(field("a residual the stream cannot code")); + } + } + let stream = e.flush(); + let v = wire::Wiv1 { + mv_precision: prec, + keyframe: 0, + n_frames: n as u32, + h: h as u32, + w: w as u32, + block: block as u32, + q: p.q, + offset, + cdf, + mvs, + rans: &stream, + wih1: None, + }; + let wiv1 = wire::write_wiv1(&v); + wire::parse_wiv1(&wiv1, Some(0))?; + Ok(Encoded { wiv1, recon, sse }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::int_payload::{decode_int, IntDecoded, IntParams}; + + /// A clip with motion: a textured square drifting over a gradient, with a + /// brightness step, so blocks have vectors to find and residuals to code. + fn clip(h: usize, w: usize, n: usize) -> Vec> { + (0..n) + .map(|t| { + let mut f = Vec::with_capacity(h * w * 3); + for y in 0..h { + for x in 0..w { + let inside = (y + 7 * t / 3) % h < h / 2 && (x + 2 * t) % w < w / 2; + let g = ((x * 9 + y * 5) % 200) as u8; + let v = if inside { 255 - ((x * 31 + y * 17 + t) % 90) as u8 } else { g.saturating_add(t as u8) }; + f.extend_from_slice(&[v, v / 2 + 40, 255 - v]); + } + } + f + }) + .collect() + } + + fn decode(wiv1: &[u8]) -> Vec> { + match decode_int(crate::codecs::CAP_VIDEO_INT_MOTION, wiv1, &IntParams::none()).unwrap() { + IntDecoded::Video { frames, .. } => frames, + other => panic!("not video: {other:?}"), + } + } + + /// Every decoder reconstructs exactly the frames the encoder predicted from, and the + /// stated SSE is the one recomputed from them, at every block size, precision, + /// search radius and step. + #[test] + fn the_decode_is_the_encoders_reconstruction() { + let (h, w) = (19, 23); + let src = clip(h, w, 5); + for &(block, search, prec, q) in &[(4, 2, 2, 1), (8, 3, 1, 4), (16, 1, 2, 9), (7, 0, 1, 3), (1, 1, 1, 2), (32, 2, 2, 40)] { + let p = EncParams { block, search, mv_precision: prec, q }; + let e = encode(&src, h as u32, w as u32, &p).unwrap(); + assert_eq!(decode(&e.wiv1), e.recon, "{p:?}"); + let sse: u64 = src.iter().flatten().zip(e.recon.iter().flatten()).map(|(&a, &b)| (a as i64 - b as i64).pow(2) as u64).sum(); + assert_eq!(sse, e.sse, "{p:?}"); + if q == 1 { + assert_eq!(e.recon, src, "q 1 is lossless"); + } + } + } + + /// A frame that is the previous one moved by a whole number of pixels: every + /// interior block finds that vector, at both precisions (in units of 1/prec pel). + #[test] + fn motion_search_finds_a_known_shift() { + let (h, w) = (32usize, 32usize); + let f0: Vec = (0..h * w) + .flat_map(|i| { + let (y, x) = (i / w, i % w); + let v = ((x * x * 7 + y * 13 + x * y * 3) % 251) as u8; + [v, v.wrapping_mul(3), v ^ 0x5a] + }) + .collect(); + // frame 1 at (y, x) is frame 0 at (y + 1, x − 2). + let f1: Vec = (0..h * w) + .flat_map(|i| { + let (y, x) = ((i / w + 1).min(h - 1), (i % w).saturating_sub(2)); + let j = (y * w + x) * 3; + [f0[j], f0[j + 1], f0[j + 2]] + }) + .collect(); + for prec in [1u8, 2] { + let p = EncParams { block: 8, search: 3, mv_precision: prec, q: 1 }; + let e = encode(&[f0.clone(), f1.clone()], h as u32, w as u32, &p).unwrap(); + let v = wire::parse_wiv1(&e.wiv1, Some(0)).unwrap(); + let k = prec as i16; + // Interior blocks: neither the bottom row (clamped) nor the left column. + for by in 0..3 { + for bx in 1..4 { + assert_eq!(v.mvs[by * 4 + bx], (k, -2 * k), "block ({by}, {bx}) at precision {prec}"); + } + } + } + } + + #[test] + fn quantize_rounds_halves_away_from_zero() { + let cases = [(5, 2, 3), (-5, 2, -3), (3, 2, 2), (1, 2, 1), (-1, 2, -1), (7, 4, 2), (6, 4, 2), (2, 4, 1), (-2, 4, -1), (1, 4, 0), (0, 9, 0), (255, 1, 255), (-255, 255, -1)]; + for (r, q, want) in cases { + assert_eq!(quantize(r, q), want, "quantize({r}, {q})"); + } + } + + /// A one-frame clip is written as the format requires, whatever was asked. + #[test] + fn one_frame_is_block_1_whole_pel() { + let p = EncParams { block: 16, search: 4, mv_precision: 2, q: 3 }; + let e = encode(&clip(5, 6, 1), 5, 6, &p).unwrap(); + let v = wire::parse_wiv1(&e.wiv1, Some(0)).unwrap(); + assert_eq!((v.block, v.mv_precision, v.mvs.len()), (1, 1, 0)); + assert_eq!(decode(&e.wiv1), e.recon); + } + + #[test] + fn refusals_before_allocation() { + let code = |r: Result| r.map(|_| "ok").unwrap_or_else(|e| e.code()); + let f = clip(4, 4, 2); + let p = |block, search, mv_precision, q| EncParams { block, search, mv_precision, q }; + assert_eq!(code(encode(&f, 4, 4, &p(4, 1, 1, 0))), "field_range"); + assert_eq!(code(encode(&f, 4, 4, &p(4, 1, 1, 65_536))), "field_range"); + assert_eq!(code(encode(&f, 4, 4, &p(0, 1, 1, 4))), "field_range"); + assert_eq!(code(encode(&f, 4, 4, &p(4, 1, 3, 4))), "field_range"); + assert_eq!(code(encode(&f, 4, 4, &p(4, 64, 2, 4))), "field_range"); + assert_eq!(code(encode(&f, 4, 4, &p(4, 127, 1, 4))), "ok"); + assert_eq!(code(encode(&f, 4, 5, &p(4, 1, 1, 4))), "field_range"); + assert_eq!(code(encode(&[], 4, 4, &p(4, 1, 1, 4))), "field_range"); + // A frame past the tensor cap is refused before its bytes are read. + assert_eq!(code(encode(&f, 4097, 5462, &p(4, 1, 1, 4))), "too_large"); + // Block 1 on a 4096 × 4096 frame is 2^24 vectors a frame: three P-frames pass the + // field's cap (2·nb·(n − 1) > 2^26), refused before a frame's bytes are read. + let big = vec![Vec::new(); 4]; + assert_eq!(code(encode(&big, 4096, 4096, &p(1, 0, 1, 4))), "too_large"); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index de59bc29b..0efb7740c 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -143,6 +143,12 @@ pub mod generate; #[cfg(feature = "video_int")] pub mod int_video; +// The integer video encoder: RGB8 frames to a WIV1 keyframe-0 payload, with integer +// motion search and a closed-loop reconstruction the decoder reproduces. The one +// encoder of the format; int_payload::encode_wiv1_zero_motion is its zero-motion case. +#[cfg(feature = "video_int")] +pub mod int_video_enc; + // Integer-exact LEARNED audio codec (`wai.audio.int_codec`) — a learned 1-D // transform codec whose synthesis is a stack of integer transposed convolutions + // integer IGDN, decoding to int16 PCM byte-identically on every machine -- 2.54.0 (Apple Git-157) From c151f800ef56b056d608932508922ad3ff5ad781 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 08:10:36 -0400 Subject: [PATCH 08/24] wai-pack: emit only what the registry pins (R6), and the encoder's vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capcheck.rs: for each rung, in order, capability-unknown (SPEC §5 does not register it), capability-not-payload (a record or a derivation), capability-candidate (no pinned payload format), capability-role (a base or floor that is not standalone media, a refinement that is not refinement-only), and under royalty_free_only license-unclassified or license-refused with the class's label, read from wai's codecs registry. A packager's verdict on a policy is the policy's refusal, then the first rung's; the refusal corpus gains a case for each that a registered string can reach. pack-conformance/encode: a WIV1 payload of int_video_enc for every piece of every fixture (its first frames), under a whole-pel and a half-pel parameter set, with its BLAKE3, the SHA-256 of every decoded frame and the SSE against the source. wai-pack regenerates each byte for byte, decodes it through decode_int and recomputes the SSE; pack_verify.py decodes each with the integer-payload corpus's own WIV1 decoder, recomputes the SSE from the fixture it regenerates, and applies R6 from the published registry export. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 2 +- wai/pack-conformance/encode/cuts-01-p0-a.wiv1 | Bin 0 -> 5036 bytes wai/pack-conformance/encode/cuts-01-p0-b.wiv1 | Bin 0 -> 2492 bytes wai/pack-conformance/encode/cuts-01-p1-a.wiv1 | Bin 0 -> 5172 bytes wai/pack-conformance/encode/cuts-01-p1-b.wiv1 | Bin 0 -> 3604 bytes wai/pack-conformance/encode/cuts-01-p2-a.wiv1 | Bin 0 -> 5316 bytes wai/pack-conformance/encode/cuts-01-p2-b.wiv1 | Bin 0 -> 3336 bytes wai/pack-conformance/encode/cuts-01-p3-a.wiv1 | Bin 0 -> 5284 bytes wai/pack-conformance/encode/cuts-01-p3-b.wiv1 | Bin 0 -> 2416 bytes wai/pack-conformance/encode/cuts-01-p4-a.wiv1 | Bin 0 -> 4996 bytes wai/pack-conformance/encode/cuts-01-p4-b.wiv1 | Bin 0 -> 2264 bytes wai/pack-conformance/encode/cuts-01-p5-a.wiv1 | Bin 0 -> 5168 bytes wai/pack-conformance/encode/cuts-01-p5-b.wiv1 | Bin 0 -> 2356 bytes wai/pack-conformance/encode/cuts-01-p6-a.wiv1 | Bin 0 -> 5092 bytes wai/pack-conformance/encode/cuts-01-p6-b.wiv1 | Bin 0 -> 2308 bytes wai/pack-conformance/encode/expected.json | 733 ++++++++++++++++++ .../encode/kinds-01-p0-a.wiv1 | Bin 0 -> 4868 bytes .../encode/kinds-01-p0-b.wiv1 | Bin 0 -> 2304 bytes .../encode/kinds-01-p1-a.wiv1 | Bin 0 -> 2332 bytes .../encode/kinds-01-p1-b.wiv1 | Bin 0 -> 1860 bytes .../encode/kinds-01-p2-a.wiv1 | Bin 0 -> 16180 bytes .../encode/kinds-01-p2-b.wiv1 | Bin 0 -> 12712 bytes .../encode/kinds-01-p3-a.wiv1 | Bin 0 -> 5064 bytes .../encode/kinds-01-p3-b.wiv1 | Bin 0 -> 2544 bytes .../encode/kinds-01-p4-a.wiv1 | Bin 0 -> 4760 bytes .../encode/kinds-01-p4-b.wiv1 | Bin 0 -> 2652 bytes .../encode/kinds-01-p5-a.wiv1 | Bin 0 -> 4760 bytes .../encode/kinds-01-p5-b.wiv1 | Bin 0 -> 2652 bytes wai/pack-conformance/pack_verify.py | 86 +- wai/pack-conformance/refusals/expected.json | 20 + .../refusals/policy/capability-candidate.toml | 15 + .../policy/capability-not-payload.toml | 15 + .../refusals/policy/capability-role.toml | 15 + .../refusals/policy/capability-unknown.toml | 15 + .../refusals/policy/license-refused.toml | 16 + wai/wai-pack/Cargo.lock | 1 + wai/wai-pack/Cargo.toml | 6 +- wai/wai-pack/src/bin/wai_pack.rs | 11 +- wai/wai-pack/src/capcheck.rs | 113 +++ wai/wai-pack/src/conformance.rs | 106 ++- wai/wai-pack/src/lib.rs | 11 +- 41 files changed, 1146 insertions(+), 19 deletions(-) create mode 100644 wai/pack-conformance/encode/cuts-01-p0-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p0-b.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p1-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p1-b.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p2-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p2-b.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p3-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p3-b.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p4-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p4-b.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p5-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p5-b.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p6-a.wiv1 create mode 100644 wai/pack-conformance/encode/cuts-01-p6-b.wiv1 create mode 100644 wai/pack-conformance/encode/expected.json create mode 100644 wai/pack-conformance/encode/kinds-01-p0-a.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p0-b.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p1-a.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p1-b.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p2-a.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p2-b.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p3-a.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p3-b.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p4-a.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p4-b.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p5-a.wiv1 create mode 100644 wai/pack-conformance/encode/kinds-01-p5-b.wiv1 create mode 100644 wai/pack-conformance/refusals/policy/capability-candidate.toml create mode 100644 wai/pack-conformance/refusals/policy/capability-not-payload.toml create mode 100644 wai/pack-conformance/refusals/policy/capability-role.toml create mode 100644 wai/pack-conformance/refusals/policy/capability-unknown.toml create mode 100644 wai/pack-conformance/refusals/policy/license-refused.toml create mode 100644 wai/wai-pack/src/capcheck.rs diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index b99163b0b..83614f7ce 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -84,7 +84,7 @@ jobs: echo "$target: $n tests (floor $floor)" [ "$n" -ge "$floor" ] || { echo "::error::$target lists $n tests, under its floor of $floor"; fail=1; } done <<'EOF' - --lib 13 + --lib 14 --test=conformance 1 --test=scenecut 3 EOF diff --git a/wai/pack-conformance/encode/cuts-01-p0-a.wiv1 b/wai/pack-conformance/encode/cuts-01-p0-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..17f5cb1f0870d61067ab6387930f37b50fb7226f GIT binary patch literal 5036 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 WxGUFsq?wz4FVEz*&#iqSw*LTfxN95$ literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p0-b.wiv1 b/wai/pack-conformance/encode/cuts-01-p0-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..136448b36a7aae41767804df9ad84e8e578c85d7 GIT binary patch literal 2492 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p2-a.wiv1 b/wai/pack-conformance/encode/cuts-01-p2-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..065ec71324c2b939c9014a71f0c39a6f988e8b10 GIT binary patch literal 5316 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 KV(SC$hW`XyL5wp1 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p3-a.wiv1 b/wai/pack-conformance/encode/cuts-01-p3-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..cdd1c620770e4a5ebb73adcd6367ccf990c031da GIT binary patch literal 5284 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 Zkny`=S>|E0hih&5opq9NlCG*R{|}Fl3)}zz literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p3-b.wiv1 b/wai/pack-conformance/encode/cuts-01-p3-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..ab1487f6a395c2058efdfc5132b8258f034f0cd0 GIT binary patch literal 2416 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p4-a.wiv1 b/wai/pack-conformance/encode/cuts-01-p4-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..443067a35a3463b6a018fe7332524c15e9eeac15 GIT binary patch literal 4996 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p4-b.wiv1 b/wai/pack-conformance/encode/cuts-01-p4-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..8f8451cbe51f64d430eb46a2de93a4af9f8e5885 GIT binary patch literal 2264 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/cuts-01-p6-a.wiv1 b/wai/pack-conformance/encode/cuts-01-p6-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..cc925b5a564080139f7091594241c2daa788f659 GIT binary patch literal 5092 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p1-a.wiv1 b/wai/pack-conformance/encode/kinds-01-p1-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..32e36600529b088e88d23056855962bf37b54a45 GIT binary patch literal 2332 zcmeIwc~Dc=9tZH7+^qNJ#){YwL=>pc1%yaNEfO&(4NEFdpsYm}3y305kq1N&@X4lF zDL72Ah#;#dv_KdHmz1Rz(1Jj<4@5u~DYB?Q4B76z@AS|5kJJC(nfc6_-`VDz?;LMu zuWb|x1aTk;(gW55EPw%wktAsi`ajz#AO`q15DUZs@qiRK4I}`Gz!@M3NCr}Xt4s(g z1g-%^KrwI~C;>`=GN2r&04jkSKoxKkxCL}@A?WQg1T6rIz!IPU-T_Kr8BhUgKm%w2 z9e@LT5`r!QBmhC+k$>_2*oW%(_y52CYo32Mj}PVj{`}uP|HJEzz$YKk9<_w`?SlgG z3JphPFWseW$nAtu@9bvm`_YXOfuum$mJ}|ZB!uxTHGGpi;LOY)v3R7 zbvEnIYK)%9i7XdWf2(#NzbL{iuY9X@ zEbrIKV}`Xpyp^iKp~v{_{@a?iPxlifOR9e7Fhy@H%csTTgNzGdQ|Q9!LCh>vg|LY> z_GYCW!8SdlzEiEI>VDcmjL3PK_B4fhnmeNLd&N@~Gzge0bTBQa>n_!0ND7M{nX%g* zUt|x)okEj()NJUbP?f!WQtKwak2|di#z$=As>s}ET;Nuwu8(@4;pf>hk8X&de7>rS zx)h#<_It9hhMgizL4-_u#AAVxeoew;iPz9F z!b6$pv9)aL6*7)s_A_>!D3o~j=8dXm?E-PD?X|Y10;cjMhnYDAR&@NlJ9DNuk(TM8 zM?+)2q>M3>;W`hT`TFOpoR5|qSOEz*htX%IMYb1dYFB-xR!$Azy4=sc06XCF0?S3 zSUh2=JJ&9yUHPXI^RiOPjM`dBcQ;kQ#dg_L-A)U7Q2Pc-GVc^>Z*0hR??`9Q)eAN2 znmV;}SP<^R%h$Ch8dJJq7L)$0fWpncM%}?{0rx&f`(x$>b;Pg*4r;%T*^gMUo`l^) zwm1nnCypgPMp2u$BDr#9sf$0J4PwH^`e@Wh%ht)ky1V422xn6Cv3KR9g|dF{*KwDJ ziUY_kPP@p;MEOPgXC_}%2hT<88hR92v)P{Ad-B2^kFruE@yF`7^&1#`x6!gTUymFQ zR66D+1<$?<)Y_$|6&hKwk`BHIf9rI{^M|oiTg5#y7uUpOSBiC;bWC=Mj`>*KaGqE! z>QfF=jEgH)`2BW2etz((GCf&ri)M{ZU#c3bb{C5?m=3>lcFuHXB(CXi!3GYjF6S=& zx6P*BWwWT$s>^&T{;MA6d`Ut^UEjL86MN14X0tb+Rr)5x$GR-6P4TcbB&%+(gs2s( zTjxKK9#o3$OAdT1KUdWGdmXizeDtuCJm0uOF|~&;Pmrh1&dbKhj3Eh`*|=p?X36Tk z;~_3ph+N5RRS2nHKOm;*e8)FF6_#}HT%oFgNGQO)r($xc{D&m}Umw{%425D#sYRn? z@O6GplO43b`r4GquG4zve7JvH8^CxR}q4ALS$|i%G{lbtkpOf_`LaO6$ G^8W`W;2u5z literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p1-b.wiv1 b/wai/pack-conformance/encode/kinds-01-p1-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..abf5976c68183acaa524df054af2b569f79b7162 GIT binary patch literal 1860 zcmdVU`#V&L0|xLjGiT0i&Y3JBX|{_b+ot5U_(~|M4Y|}H({BUU{o1=_lj3`DlBZeVn#4@sB0LWqFGV&Pti~>d><1QZn$aw%zFq8}xL(QNV z8b&$|05k&tSPbUKe+UQxz-6V6)QNd*q)EB=UUOTmr_?fgt(-aP3(ki2u)Rmq0rL&- zp*^-s`A+SB;)9d>5fh_G{E5I%W4-e{Id&qQoR(0eJTY6jWN)peq3}Gl$HEGZYMtTe z>8=OGU4<-Cc9Z|!z6N(~6d--P3}U+xO1B(-qyEe9y28TyFzN1Isn}E^(6sn3(ky&B zMVY)ygk9Y}g00`b0v)CWpp~5olJa&ZZy-sAf0Z7t_|!0|?7Y-Lxn2K5^{qal5}h_C z58d8HZ|(|HmOFNE`8zDRSgZ}yU^&ONyJd>4k45lDw+6A5Zu?M6F^~Gn-da6U6QVBM zc}lGZ-BDjW*`fX+zLgA}iDd1x5Q3Z6Wu z)!cHB&e;^KNU3yEuQi!hgzeL)N=o!8LbMt_STo9w3Act;RON6bp$m|<^OHQ-!-$w` z-^WAU*{a`jf~Z#y?UgRte}32^9g6_1_a#CNQ5Ak13JVN3nL6=Qm< zRjB(`&9b0!Dm6<7g0=d%^D+m}RH$L2X=|WC&r*(_&pl3}ybXem`(oH`1OA58No@K^ z4l2d0=)O-IH4lfR3WLZ};3_wb?cW&+C3$CXlO)lgP%PqHOs@wyr7rMPmKMHGcO%bt zPz~pmh|u8nYR#N|CTU5RQBK|E>hTyo_Qf?IwD8;+NK74L=@2i#hd#Twl~cB0MQA_d zWigKq&TH|8OCZ$mrH0f-5LIl;HtJs7*9tm@nN0 zzxFr@?H0Gf-%i4aj;}2;*Bik@{x`14tft5ViZ1eI%-0H)$dqG0@D0aoxfE(%ehT<7 z(#fqf-p)!)xWl=ue-QoKOoP4k@ZwQcDpU}flqa$sLQZd=Aj-WDp--d6vFBtI(Qloi`0V3DIqYy%cK>Xt2rhWbzF+hc_Y|JZ za_`;>d5w<~;=X($wksGjrzH6NRv4Qt&*E1%_VF$trMPnCNoC!aS1E`)OIm$lN*BjI z2ErYhI98og;H6X>c$J?ymihl$q}f7@uks7zjdd-@i*9IRva4J0rCy+B!ceAmGgzd$ zvL#>bQsBbb(Jkam)~*Ky#yITNTjyvGd3QxmnfGEqw{e%G4Z5P zb;ZMwZj)zGQ}OyrgQjrE=Eelu>q0g+G^Ch_`$dfW28oC-jeCi8?l<8hM@!LVm$Xeo($ytWDuPpQtKRSI*&$_t`(l47s;uy`VI%3V*$y!;?Kq|T2=7}1yMQ}@ zTP&$7nicv9%*PVC&TMGum27N`|1;_C@-yF2Bd>p4)lqZgyGL8ES%!tz7CmNDbaF($ zM_%-%d{3}-OMSvwn&VkQ|I%Gnd_~$k%8_=SZNlAv={tM?e!@4huH_U*PIt`73eABc zbWkRkYS%NV%FhV6suRzu$O%f8hYUOF`qe+MLVIk;pAO%(|CrM0f4U=gc3quwk|bH` JnKdA}{twjU^~wMM literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p2-a.wiv1 b/wai/pack-conformance/encode/kinds-01-p2-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..99d7fe0b785afa52b73e501cecf9e1f4aebfbd05 GIT binary patch literal 16180 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

KPoU zcZVPPM3ZotO@S-cq3P(tO}C5q{%1|gN4}K0z_e?^%P(JR{Bv9d61bjX;)yM1#tp1d z+`6y5_&0x-FU&rZmIl~Vht1Qkw#QssLd3wBAtUv}404bB5|5f)6|YNfRu-qq_-$IPA?x3? zu;XkLtZaHeT9eAQ%9pl0o<~gU~80ey^|CrDR4yC3@Jw{evCz( bx2^x~FgejIAQJd9OV8Dy literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p2-b.wiv1 b/wai/pack-conformance/encode/kinds-01-p2-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..9c4354385ef6d8eccf43afe45f8ad8610184a91f GIT binary patch literal 12712 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p3-a.wiv1 b/wai/pack-conformance/encode/kinds-01-p3-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..6ffbc9f2dd5e9ca505d1ad86b8b64352aef7b34c GIT binary patch literal 5064 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 F{SPZ!>ns2O literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p4-a.wiv1 b/wai/pack-conformance/encode/kinds-01-p4-a.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..0942b76a64a022885eaa96f38af3432fa114c40a GIT binary patch literal 4760 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 literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p4-b.wiv1 b/wai/pack-conformance/encode/kinds-01-p4-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..9cec20bfc2d135c71b45d46bfe7793bf7a48046b GIT binary patch literal 2652 zcmchXc{tVi7so%}`~BX{K8$@Q%gq*9hV1*A=!&Qm*+W#;k!)Eq)>bhRDMmF>xmhxn zp>VY!2_ac-+2UI6^1Y+q^PB&EfB(+&yqv%dG(uObq@Bjb=caYmbV22-i zi^Jjk$o;Uo9b_Q@jCXpAU;wP;ubz#1 zTJrJ*u_#NDcemurz9#I>4z9lt8>s9YL%V z9^u8_D&VWf@^NK*FRU+rq_CWZPOv80vpDX>r2wB~59cnw#hYHl!8Zqkj$!mn2sgUs znBCpq+2ztp+tTe(oIq+A@=#tMCh4gY4tc!B9H8O6mjYYyrsot`Z|@qQk_yMSZz}nr z>eYpas#Y(wohU+(`5S}u{ydLcx2W0J$TMVRk3)x-^Tec_Q0y49(bG>jyc;(yCh07$ z|2G}nE#GbV{E$36X_Pj!@On2>#=@GjYB|SLdF29q?Q6t!?x{xnDwm1BY3pqoN;z}I zbG<#$WUY<*-lQ?8slJ@$EG819&5fbEMiGSHQ{l3uuXy@|5qDnb3jNwSEP657i)kyM z0F`S^B9|P5cKWh$a7hlLSkX-oB1drTQvbN&b8MZx=kf=p$@C0#*y91r_jfRqFx87| zL;Jb31-LGZ-)>~}rJ17>dV;L%5CX^fMk2CnBnLW2i$mI#b$EN9dalV4Wq7YKM4coi zt+q4VW6gN8cm zfpMG#wgP*U>mf%Hf5}eYiQZ?tL0k%Bt|pLCLET8^nWsJwfBF%`Y+4c1$2HAmFAVX_ zR(BPN0ChG!(u|q+S0(fFP&c~W!9?Q2)u7C7A*4d-Bwx7HCp!MIGLgr@?d8`L>UNXz z0vo!z%{hE<4mkUWVx3Oo@zF9C1izRuK>~GY^;Wellj@g_=KK-Lbe$-GR2GvE$=U)) zL5;vQ*0Gm>c$_|?=Nik`<0i2#XPbe}bQCirYBivrzOZ2b(R)yHmp1=9c@_L(>H9s4 zf$1z>#eHa`NGwC|@*0{Kr-}GWbnW~QS){h%F&zIi5$8Eny3SMc?uwbR+xCk;E}-d0 zia9(@O&~vN0lrip2_NtFA{3Ll27VLm-FR%7&kPMtLrtxjY^PEqWHU+{68_wP3^<8$ zUGH-ue3WM!tQ%RawKjAy)ohQT{fC#C3L{Dw!^?Kqh1^;A%R~mTq{#s9(;yw6^>aG= zOXnn`8OLUt__d-j>67rtYZc7KmwWKkTmr9d$;qwxg(_lkZ0f4BivxpQUe10oRL6-$ zI)R+=1ndvn5$-hZ#*@Z-B#UWj{RdQUzNUE*hMN?Xq9vV()G5=U!h)799}^GZ2hFb* z{1)HZju+Ac2B7LX3HQX>EaN9JQDfIjo*7B09+OLYGvsEw5Q#)BM`!a}PnE?eH|mQd zOXwtzjj9<1=RAn#rhwh{bsCn+&$!{z$ZA(?ODFQ#QTJhe$YABSJ@>d06Tf0@wxhoGR%qzTJ~2=aHc`H$>;e2W5Bb z_xLwSw2Ml6_mc?9M=MhrPR19eDoIYKkq-)fjUdPE>if2Np(FZ@n@c&pNXy)H?&y^? z_i)cfPnyLx1@Tf8d4FL~B`PY;p{IQ-@{ zc+w}gb9GIUV>1}{>L<{vDrwW~1kESG9-5?vWwmMg_2F3!DFf`GaCypD`3!P}Kebc% zsLQY1MdF2gj_->t#!o&rp0j@wX4k&0lWexmARn{WYu|M_< z%SMTOTR_q$uk5(~Go>`mTq$lnsO_3-l~9#MM}T?NCb_U`D!Jz*2-d$jBBDo{_2R3Z zG15xZ=c?{6dvR7FXxlPHT+PaTpIE)QiFf!isVZVySXgYlou&O^?S)QlGAQo( z7Vwd(7$CWxB?Z6XUwI_ZDm65#J`sG@;#Lo-_(i$z>K?lCkgI*cl&9r@s77k^K7v!; zetu|#9KTvIGZiuZ%42yYAM^AE1QktmTPgFjm!98uf+8VgU|_~?ooGDOsS%(PS`F!+ zRU?uf&xbYi>A6ti7HV(n2Yj;k%e=##t)%T)Ey9+x6&)|UqjO=zW-+ETe`akn=_<)qeQ){~_4>2POE(b;WmZjxWGHlXibWf$k>&|>_tj9DiXQmzyOCeFGsI|%i z<-YPFxW4=Kf7J|Ju~y;HOL7=*!<7G=Dx4Zuve=vZ-T8D@V2Vky@6&*%A<^LtOS)UR Y5h=~&9IyKphJW&D-hNd0JYCq~AI{K_nfN~QAC#{X6@ z&1)Jy+FDy0I2Zt0XoMgDfPwlQo(KRhNC3b}27vc8J`@1J(A=T|KpM>u4V4A}XifiL|4-n55?E#e zfSbU1EY^+=C?Kdn||1)FpJ(k-K@HQ`qTu2@)PT zpa-9n!0ld+aCwKb;K*ckI^@s0jpbVd*X3s%sO=rT7qW@yQw?jz3{LDx*=Txbg7;CD))iPi_=mUV`S%*?@@gW0K)S&3) zPV6HNgM&Q`I2l`*LR9})LveqfPF(-j1!|Pc0!I&q!u>=qg73`)KsZrBl5u~&`egkT zL%G%+km8v(aXE~vWb64Z>N^n-6c=F)>a$M<6Nil8`xAWaA@MBI5Zph69m#Ou2hm;^pn4f5 zP_3cv0BU*}FqmD3COuw5*zVUrO}fjlzYnJOhY8;dbTQe)YqCLN7}Ibk#wvv`>(j&6;siyq4s~-EP{wS0G*lO^}5(H~6hD zH(bxSpR=PUj{$j=9>p*pL((M7Vb$W7NEhiJQn5x;R1k0hG<8)3)X4&ct=e#+lpI)K zJS#akB6kONXCso7s)r&K4{eh)XI-g1Xft5Jau7gsOF(nHju4U&R?zb+i`d_i!|Mf# z;Y74KlBjoFN;$oDNRZ)00v*}RU{WF!UY;n9c)JUM7IqKl-XE$JntCXPk{hRx8g|h* z&wCfhYA%eFOce#lef>Pxa&{CLlj9Co_IPmKWY3n7!NU!CQKk~F8QzI?j94OdIJr`w zQ4`b&jU8z8pdg4&(Fw+=T!Ku1^3!|w`QfG%BCr>_)`;r4#U$3}SW=!3?j?T0MNUFgpG2%R23WD|@4nn;TW6Ws@yjQvEYRE+c}12aiKC{VZUAZAqX^bqV;ubPboU z=+rqK*LGwQ-zR*)Vib0s`6j;XiUy_nV3&$hFo0eQR|NI1gu|NtmcVO=g1IYvc9@t_ zMNk#q&Lprq4Cg6YOIr4JrRF_7q^{0y00_NnAWq|Ru;~*Rdylp|76B|F^i$o4PWm7s z>wG=Mp9@QA_$&_)_O-zmLq0<;R*KQ`UWe$c6eDI506NI-3#58?C26CkoO~C>OIQ!x zCM#~%fbg(pSYyK`=w#$J{8m6coj}4cT#Hv9-`pz1F*c@4eiEvM59HV;!^lI_q|FLw zD2FL%?vD<*?no1UX}*Bf!DE**V*W7_xne`AJb8}of=`la9|cf>JUn#*O$4@8r9g*w zsL)TtrAWp@B^3LIIoxJ)4vz5tC6*9#ha^*~MMe=`Qi0fF;CvngWZkv|?JeL$nv0b| zSJu3+wm zCy>7xZ{nqRkI9Z^&nZs%2GFJoZJ6|y5?I3>3ll~qbErr4!SCAqM2eLn$o3I72jGT`6=T4&*hMNHyBCM8X9*BjN$8K!PXrT8_(Rf}BQ&w~)GX16}+PA(6%Ke?Lf z{rC~EiU@J?6$Szh;Py8d zA*o!Obg5&&di6720z75*<|>^rIjbd%_GT|o@bQm9nUQ0#uA9+t)O|0OZ|LAgOQl5#m2tQ1OGD$Ww`RI)p?uR*_z8x4PJy z^mNILdKh|!bj}D2d=nZ0cYXK;IVUTEJQmQ0_AbXGnv--UUCz12q~AtMO;+wFjl40| zKvZ|cK72j+u+G9HFaC0oJ98lBCOpFNnl_75e(c>}9Ud`0m9o_pQGs$w6;94MDs-G3 zX6-C^+d-17kqN~eJ)k8x>!NX|GZb;PIML zOAu7i!muRmm&6ixvjhF`*N0)gh|bg-b8-o9Wm=I-AoVgK^Qr~YbHl&BtNT>bgF?pj z$M)q!%aiinjLNl+gbsldN@?Fgh~*T#4f9*Zs&AxKO5V@SNUg}h6K%<)W_J<7YPevj ziD|j@M0-7gb9RR3lTw+DQOG|*tADLG3C(tEio&0_Se`~FT+@%$6!Z|%o(o!AU43Xt z-}mOr+mBNo66|jC=8dC1-ug~<7wlrz?qSf68fCV5^K<->mX1SD*|6d!bKks*YAFF< zzTdP&A4o0ff2wddAM^^nmA!RPWqbK$R++THm*|lw-m~>%BgRPMjPSuXTp!=nN~9@b zjyzLB?sg*T<6KP)_|taLz2iSZ1nLf)`p)E){NQ_}xFU1UNI!H$>?x~+ap{7xD=ySC zzWwSIbkzrstO2?hg?l+En%KKO_m1^LbxCnW5-lmlN)F!S`-cMaB65;e53TQI=%p>Y z{}7zVbt&KVlin~|x2eZ#6U_MYpRG9}P}!qRzFoX8Et3jPFEh>ih27RL}Aqh3_8o%CI@|F)gI98Px6F zag-BoC?BcGw4Q(IXvW*6=!qTK+f^%8j&uSr8A5A{ZnNFxtU@Y$<;l|0fj4|tJ|47A zj@TghW&*!St;P{JX7^6%QuGqDa@fY03#!UO-&y&X7{yyGiZ|J21ieTMu99F6w!VV7 zH7P$et(bnvzWlUvUQfS$Tzabgn~Lv>$W&=Wv*H=E@U3fYN8xw8)&et{9UWJoB{{{a zH=@>Js!cXb{lm8H*L?mY7CQIxyqir7ERy?qGVk9i_oU6xqM~k~ZXv{fK2r2+wWQUw z1I%zTD>jtlD_iLo(LeRi7nkau%6}+DKoZMMEY=)HFh?XdknMD`c1T(w?t7W{U^J{*=F*M zjI#RHFja4iTS)Fl<;Y5JDPPXgFV)}ucdma;Wh3(YIqP5SlT^+dr5}xV-CdSccPMF_ zy3taW>+RzG$(G4@Yg#@vFavQwc)GCdF{R(fzz!_48q|-Atr**%b*d#P*l7<_%tj3w*WRUZz@Wzp2avY zPkEwlP(b!c@>r!zgU_ew&iYHW*&E2`PnEMwC;9sq1Bg!u1u27z`Hd2HO>3=hcsx>R zdewQS=@t9T!c0O@!9bpeYh~#x&Bvy<*eaqqZV!4!HD%okx?L&xaz*+5g^OX8VwVi* zDL!t=Ujo0T#c_-?mtAyHl9%;~77~o)MADD%@h(X{vrse9D0a37AWG_!Q;PjsKL^xc z2rr+pY1aFPl2z4w-2+)qv&O`ZpLawg>PUv)S@|LD%w?BBB)xTL&>Z->hdojya0&EP zF%6$7I^rnm5)KL7624}LT>e*iedlGTjJR9MYHOGoCPgCnDLj{c_=lJH>4LOtSov*M zRlh@>7ijqpUB^7J+ZQbB+`UG>W{okiSBtEwag0%?aX)&su7hUW1O=H8W-<{ANWtIgl$%ebK3%RN`e`??L7@^v4l8$tnft=5d znB2#C90=k**d;lJLn73=r;X#Z5;nFZWKgqu*a@fd^ce>(g!&_#$+OA)|q<0U6yk90B+Srw+m}jzj_}epWwb409+g^^@D~Kp1X7m15 z|7Z@SwjBDRbm@AD%}n+~(JJ-fjQ&m?b-4(YC^4BCY~{a`QI)2Zx|NBH4Tn}2zUY(G zS0$|;vJ%&{j!JkN1D-9^3nVFxsR`KbPFy`~|J5qK*qg%b$v>+6K*g4EKEx`xE=_vj zePo!9oV=mcVNTy=N{%*sb)qrldfK~F$ZSIrIZh-zParmtr}bm0yyrDBv{~^AcYE0H zbha|y;n${&P|jo{opPN$1g8IWJ$H}s?MQT^_?h@glY2pxapLXq=ir6mcbC$uUu~Nc zsVd{{%Y=ou4+moKCvC|et=eCy_S{I)UFxBi&{Z{F_$l+EZNIb}mc5uJ7~eT#nEH2u z)XV6&CK90Vo`}_oDKy!KxfbYndljZs8!-nbO6F#-Go}5)_bO;=T=VJ4*I{@T`Ba&JFve;o!G!`$`==K3?jQ{c%t5KV2Z?-T(jq literal 0 HcmV?d00001 diff --git a/wai/pack-conformance/encode/kinds-01-p5-b.wiv1 b/wai/pack-conformance/encode/kinds-01-p5-b.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..9cec20bfc2d135c71b45d46bfe7793bf7a48046b GIT binary patch literal 2652 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 Y5h=~&9IyKphJW&D-hNd0JYCq~AI{K_ @@ -31,10 +37,14 @@ import tomllib HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) +sys.path.insert(0, os.path.join(HERE, "..", "int-payload-conformance")) from _blake3 import blake3 # noqa: E402 +import int_payload_verify as IPV # noqa: E402 MAX_INTEGER = (1 << 53) - 1 U32 = (1 << 32) - 1 +# The frames of each piece an encode vector codes: the first ENCODE_FRAMES. +ENCODE_FRAMES = 12 # ---------------------------------------------------------------- canonical JSON (RFC 8785) @@ -493,6 +503,40 @@ def digest(P): return blake3(b"wai:pack-policy\x01" + jcs(P).encode()).hex() +# ---------------------------------------------------------------- what a packager may emit (§3.6) + +with open(os.path.join(HERE, "..", "registry", "capabilities.json"), "rb") as _f: + REGISTRY = {c["capability"]: c for c in json.loads(_f.read())["capabilities"]} + + +def rung_refusal(cap, role, royalty_free_only): + r = REGISTRY.get(cap) + if r is None: + return f"capability-unknown:{cap}" + if r["kind"] in ("record", "derivation"): + return f"capability-not-payload:{cap}" + if r["status"] == "candidate": + return f"capability-candidate:{cap}" + if r["role"] != ("refinement" if role == "refinement" else "standalone"): + return f"capability-role:{cap}" + if royalty_free_only: + if r["licence"] is None: + return f"license-unclassified:{cap}" + if r["licence"] != "royalty-free": + return f"license-refused:{cap}:{r['licence']}" + return None + + +def admit(text): + """A packager's verdict: the policy's refusal, or the first rung's.""" + P = parse_policy(text) + for r in P["ladder"]: + code = rung_refusal(r["capability"], r["role"], P["royalty_free_only"]) + if code: + raise Refused(code) + return P + + # ---------------------------------------------------------------- sources (§4.7) @@ -851,7 +895,7 @@ def verify(d): b = load(os.path.join(d, "refusals", c["file"])) if c["file"].startswith("policy/"): try: - parse_policy(b.decode()) + admit(b.decode()) got = "ok" except Refused as r: got = r.code @@ -879,6 +923,7 @@ def verify(d): p = {"min": units_from_ms(s["min_ms"], *tb), "target": units_from_ms(s["target_ms"], *tb), "max": units_from_ms(s["max_ms"], *tb), "cut_abs": s["cut_abs_permille"], "cut_rel": s["cut_rel_permille"], "window": s["cut_window"], "lookahead": s["lookahead_frames"]} + cut = {} for fx in sc["fixtures"]: b = fixture(fx["name"]) rep.check(hashlib.sha256(b).hexdigest() == fx["sha256"], f"fixture {fx['name']}: SHA-256") @@ -893,6 +938,35 @@ def verify(d): "mad_milli": mad, "changed_permille": chg, "occupancy": pc["occupancy"]}) rep.check(got == fx["pieces"], f"scenecut {fx['name']}: pieces") rep.check(plan_digest([x["start"] for x in got]) == fx["plan_digest"], f"scenecut {fx['name']}: plan digest") + cut[fx["name"]] = (frames, got) + + enc = json.loads(load(os.path.join(d, "encode", "expected.json"))) + want = [] + for fx in sc["fixtures"]: + for i, pc in enumerate(cut[fx["name"]][1]): + for s in ("a", "b"): + want.append((f"{fx['name']}-p{i}-{s}.wiv1", fx["name"], pc["start"], min(pc["len"], ENCODE_FRAMES))) + rep.check([(v["file"], v["fixture"], v["first"], v["frames"]) for v in enc["vectors"]] == want, + "encode: one vector per piece of each fixture and parameter set, at the piece's first frames") + names = sorted(n for n in os.listdir(os.path.join(d, "encode")) if n.endswith(".wiv1")) + rep.check(names == sorted(v["file"] for v in enc["vectors"]), "encode/: every payload is a vector") + for v in enc["vectors"]: + b = load(os.path.join(d, "encode", v["file"])) + rep.check(blake3(b).hex() == v["blake3"], f"encode/{v['file']}: BLAKE3") + try: + w = IPV.parse_wiv1(b, 0) + frames = IPV.decode_wiv1_k0(w) + except IPV.Refused as e: + rep.check(False, f"encode/{v['file']}: refused {e}") + continue + p = v["params"] + head = (w["block"], w["mv_precision"], w["q"], w["n_frames"]) + rep.check(head == ((p["block"], p["mv_precision"], p["q"], v["frames"]) if v["frames"] > 1 else (1, 1, p["q"], 1)), + f"encode/{v['file']}: the header's block, precision, step and frames") + rep.check([hashlib.sha256(f).hexdigest() for f in frames] == v["frame_sha256"], f"encode/{v['file']}: decoded frames") + src = cut[v["fixture"]][0][v["first"]:v["first"] + v["frames"]] + sse = sum((a - b) ** 2 for s, f in zip(src, frames) for a, b in zip(s, f)) + rep.check(sse == v["sse"], f"encode/{v['file']}: SSE against the source") return rep diff --git a/wai/pack-conformance/refusals/expected.json b/wai/pack-conformance/refusals/expected.json index 589f322fe..d385d7905 100644 --- a/wai/pack-conformance/refusals/expected.json +++ b/wai/pack-conformance/refusals/expected.json @@ -1,5 +1,21 @@ { "cases": [ + { + "file": "policy/capability-candidate.toml", + "reason": "capability-candidate:wai.splat.sog" + }, + { + "file": "policy/capability-not-payload.toml", + "reason": "capability-not-payload:wai.video.package" + }, + { + "file": "policy/capability-role.toml", + "reason": "capability-role:wai.meta.hdr10plus" + }, + { + "file": "policy/capability-unknown.toml", + "reason": "capability-unknown:wai.video.nope" + }, { "file": "policy/class-layer.toml", "reason": "policy-class-layer" @@ -40,6 +56,10 @@ "file": "policy/lead.toml", "reason": "policy-lead" }, + { + "file": "policy/license-refused.toml", + "reason": "license-refused:wai.video.avc:patent-pool" + }, { "file": "policy/missing-key.toml", "reason": "policy-missing-key" diff --git a/wai/pack-conformance/refusals/policy/capability-candidate.toml b/wai/pack-conformance/refusals/policy/capability-candidate.toml new file mode 100644 index 000000000..91c145693 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/capability-candidate.toml @@ -0,0 +1,15 @@ +# Refused: a candidate capability, whose payload format is not pinned. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.splat.sog" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/capability-not-payload.toml b/wai/pack-conformance/refusals/policy/capability-not-payload.toml new file mode 100644 index 000000000..cd6f1b9d6 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/capability-not-payload.toml @@ -0,0 +1,15 @@ +# Refused: a registered record, which names no payload format. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.package" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/capability-role.toml b/wai/pack-conformance/refusals/policy/capability-role.toml new file mode 100644 index 000000000..ee6cf4598 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/capability-role.toml @@ -0,0 +1,15 @@ +# Refused: a companion-only capability as a base. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.meta.hdr10plus" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/capability-unknown.toml b/wai/pack-conformance/refusals/policy/capability-unknown.toml new file mode 100644 index 000000000..b7400a334 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/capability-unknown.toml @@ -0,0 +1,15 @@ +# Refused: a capability SPEC §5 does not register. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.nope" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/license-refused.toml b/wai/pack-conformance/refusals/policy/license-refused.toml new file mode 100644 index 000000000..51685a95b --- /dev/null +++ b/wai/pack-conformance/refusals/policy/license-refused.toml @@ -0,0 +1,16 @@ +# Refused: under royalty_free_only, a capability the registry classifies otherwise. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" +royalty_free_only = true + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.avc" +role = "base" diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index 8700f25c4..bbccb4b60 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -473,6 +473,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "wai" version = "0.3.0" dependencies = [ + "blake3", "cbindgen", "pkg-config", "serde", diff --git a/wai/wai-pack/Cargo.toml b/wai/wai-pack/Cargo.toml index 21bc630f6..0bf350849 100644 --- a/wai/wai-pack/Cargo.toml +++ b/wai/wai-pack/Cargo.toml @@ -19,9 +19,9 @@ name = "wai-pack" path = "src/bin/wai_pack.rs" [dependencies] -# The standard's crate: canonical JSON (ungated) and, from W4-2 on, the integer video -# encoder. No codec library is linked. -wai = { path = "../wai-rs", default-features = false } +# The standard's crate: canonical JSON and the capability registry (ungated), and the +# integer video encoder and decoder (video_int). No codec library is linked. +wai = { path = "../wai-rs", default-features = false, features = ["video_int"] } # TOML 1.0 (the 0.8 line; later lines read TOML 1.1, which a policy must not use). toml = { version = "0.8", default-features = false, features = ["parse"] } serde_json = "1" diff --git a/wai/wai-pack/src/bin/wai_pack.rs b/wai/wai-pack/src/bin/wai_pack.rs index 93c55bd0a..5b7a6bbcf 100644 --- a/wai/wai-pack/src/bin/wai_pack.rs +++ b/wai/wai-pack/src/bin/wai_pack.rs @@ -2,8 +2,8 @@ //! //! ```text //! wai-pack policy [--royalty-free-only] -//! the effective policy's canonical JSON, then its digest; a refused policy prints -//! its reason and exits 1 +//! the effective policy's canonical JSON, then its digest; a refused policy, or a +//! rung whose capability a packager may not emit, prints its reason and exits 1 //! wai-pack plan --policy --component [--source ] [--out ] //! segment a video component with wai.pack.scenecut/1: one piece per line //! (start, frames, flags, kind), then the plan digest @@ -44,7 +44,12 @@ fn cmd_policy(args: &[String]) -> Result<(), String> { let p = load_policy(path)?.effective(&o); println!("{}", p.canonical_json()); println!("{}", hex(&p.digest())); - Ok(()) + // The digest is the policy's; whether a packager may emit what it asks is a second + // question (packaging §3.6). + match wai_pack::capcheck::check(&p) { + Ok(()) => Ok(()), + Err(v) => Err(v.iter().map(|r| format!("ladder.{}: {}", r.rung, r.code)).collect::>().join("; ")), + } } fn cmd_plan(args: &[String]) -> Result<(), String> { diff --git a/wai/wai-pack/src/capcheck.rs b/wai/wai-pack/src/capcheck.rs new file mode 100644 index 000000000..8586ddad1 --- /dev/null +++ b/wai/wai-pack/src/capcheck.rs @@ -0,0 +1,113 @@ +//! What a packager may emit ([packaging] §3.6): only capabilities SPEC §5 registers as +//! payloads with a pinned format, each in a role it can play, and, under +//! `royalty_free_only`, only those the registry classifies royalty-free. Read from +//! the registry of the `wai` crate (`codecs`), never from the policy. +//! +//! For each rung in ladder order, the first that holds is its refusal: +//! +//! 1. `capability-unknown:`: SPEC §5 does not register the string. +//! 2. `capability-not-payload:`: it is registered as a record or a derivation +//! operation, which names no payload format. +//! 3. `capability-candidate:`: a candidate, whose payload format no revision has +//! pinned (SPEC §5, *Candidate capabilities*); an encoder MUST NOT emit it. +//! 4. `capability-role:`: a base or a floor whose capability is not standalone +//! media, or a refinement whose capability is not refinement-only. +//! 5. Under `royalty_free_only`: `license-unclassified:` where the registry gives +//! no licence class, and `license-refused::

" ); @@ -88,6 +92,15 @@ fn cmd_plan(args: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_source_verdict(args: &[String]) -> Result<(), String> { + for p in args { + let b = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?; + let v = std::panic::catch_unwind(|| conformance::source_verdict(p, &b)).unwrap_or("PANIC"); + println!("{p}\t{v}"); + } + Ok(()) +} + fn cmd_fixture(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("list") => { @@ -138,6 +151,7 @@ fn main() -> ExitCode { let r = match cmd.as_str() { "policy" => cmd_policy(rest), "plan" => cmd_plan(rest), + "source-verdict" => cmd_source_verdict(rest), "fixture" => cmd_fixture(rest), "conformance" => cmd_conformance(rest), _ => return usage(), diff --git a/wai/wai-pack/src/conformance.rs b/wai/wai-pack/src/conformance.rs index a7aac7fe8..5fe0255c6 100644 --- a/wai/wai-pack/src/conformance.rs +++ b/wai/wai-pack/src/conformance.rs @@ -97,19 +97,24 @@ fn policy_expected(dir: &Path) -> std::io::Result { pub fn source_refusals() -> Vec<(&'static str, Vec)> { let frame = [16u8; 6]; let y4m = |head: &[u8], body: &[u8]| [head, body].concat(); - let wav = |tag: u16, channels: u16, bits: u16, data: &[u8]| { + // A WAV file with its block alignment and data length stated, so a case can make + // either disagree with the rest. + let wav_with = |tag: u16, channels: u16, bits: u16, align: u16, data_len: u32, data: &[u8]| { let mut b = b"RIFF\0\0\0\0WAVEfmt \x10\0\0\0".to_vec(); b.extend_from_slice(&tag.to_le_bytes()); b.extend_from_slice(&channels.to_le_bytes()); b.extend_from_slice(&8000u32.to_le_bytes()); - b.extend_from_slice(&(8000 * channels as u32 * bits as u32 / 8).to_le_bytes()); - b.extend_from_slice(&(channels * bits / 8).to_le_bytes()); + b.extend_from_slice(&(8000u32.wrapping_mul(align as u32)).to_le_bytes()); + b.extend_from_slice(&align.to_le_bytes()); b.extend_from_slice(&bits.to_le_bytes()); b.extend_from_slice(b"data"); - b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data_len.to_le_bytes()); b.extend_from_slice(data); b }; + let wav = |tag: u16, channels: u16, bits: u16, data: &[u8]| { + wav_with(tag, channels, bits, (channels as u32 * bits as u32 / 8) as u16, data.len() as u32, data) + }; vec![ ("y4m-header.y4m", y4m(b"YUV4MPEG W2 H2 F25:1\n", &[])), ("y4m-interlaced.y4m", y4m(b"YUV4MPEG2 W2 H2 F25:1 It C420jpeg\nFRAME\n", &frame)), @@ -124,6 +129,11 @@ pub fn source_refusals() -> Vec<(&'static str, Vec)> { w.truncate(w.len() - 2); w }), + // 2 · channels past 2^16: a block alignment equal to the wrapped product. + ("wav-format-channels-32768.wav", wav_with(1, 32768, 16, 0, 0, &[])), + ("wav-format-channels-32769.wav", wav_with(1, 32769, 16, 2, 0, &[])), + // A data chunk that declares 4 GiB − 2 bytes and holds 4. + ("wav-truncated-huge-data.wav", wav_with(1, 1, 16, 2, 0xffff_fffe, &[0; 4])), ] } @@ -134,8 +144,9 @@ pub fn source_verdict(name: &str, bytes: &[u8]) -> &'static str { } else { let read = || -> Result<(), crate::source::SourceError> { let mut r = WavReader::new(bytes)?; - let n = r.remaining(); - r.next_block(n)?; + while r.remaining() > 0 { + r.next_block(1 << 16)?; + } Ok(()) }; read().map(|_| "ok").unwrap_or_else(|e| e.code()) diff --git a/wai/wai-pack/src/source/wav.rs b/wai/wai-pack/src/source/wav.rs index 966fae97f..bc8f1287c 100644 --- a/wai/wai-pack/src/source/wav.rs +++ b/wai/wai-pack/src/source/wav.rs @@ -2,6 +2,10 @@ //! read a block at a time as interleaved `i16` ([packaging] §4.7). Anything else is //! refused `wav-format`; a chunk that ends early, `wav-truncated`. //! +//! No field of the file sizes a buffer: a block grows only with the bytes that arrive, +//! so a `data` chunk that declares 4 GiB and holds a few bytes is refused +//! `wav-truncated` having allocated what it held. +//! //! [packaging]: ../../../../extensions/packaging.md use super::{SourceError, SourceReason}; @@ -67,7 +71,9 @@ impl WavReader { let rate = u32::from_le_bytes([f[4], f[5], f[6], f[7]]); let align = u16::from_le_bytes([f[12], f[13]]); let bits = u16::from_le_bytes([f[14], f[15]]); - if tag != 1 || bits != 16 || channels == 0 || rate == 0 || align != 2 * channels { + // 2 · channels is formed without wrapping: 32768 channels and an + // alignment of 0 is a contradiction, not a match. + if tag != 1 || bits != 16 || channels == 0 || rate == 0 || channels.checked_mul(2) != Some(align) { return refuse(SourceReason::WavFormat); } format = Some(WavFormat { channels, rate }); @@ -96,10 +102,16 @@ impl WavReader { } /// The next block of at most `frames` sample frames, interleaved; empty at the end. + /// The block grows with the bytes read, never to a size the file or `frames` + /// declares ahead of them. pub fn next_block(&mut self, frames: u64) -> Result, SourceError> { let n = frames.min(self.left); - let mut b = vec![0u8; (n * 2 * self.format.channels as u64) as usize]; - read_exact(&mut self.r, &mut b)?; + let want = n.saturating_mul(2 * self.format.channels as u64); + let mut b = Vec::new(); + (&mut self.r).take(want).read_to_end(&mut b)?; + if (b.len() as u64) < want { + return refuse(SourceReason::WavTruncated); + } self.left -= n; Ok(b.as_chunks::<2>().0.iter().map(|s| i16::from_le_bytes(*s)).collect()) } @@ -153,5 +165,18 @@ mod tests { let mut t = wav(1, 1, 16, &[0; 4]); t.truncate(t.len() - 1); assert_eq!(code(&t), "wav-truncated"); + // 2 · channels past u16: 32768 channels with alignment 0, and 32769 with 2, + // agree with a wrapped product and are refused. + for (channels, align) in [(32768u16, 0u16), (32769, 2)] { + let mut x = wav(1, 1, 16, &[0; 4]); + x[22..24].copy_from_slice(&channels.to_le_bytes()); + x[32..34].copy_from_slice(&align.to_le_bytes()); + assert_eq!(code(&x), "wav-format", "{channels} channels"); + } + // A data chunk that declares 4 GiB and holds 4 bytes. + let mut big = wav(1, 1, 16, &[0; 4]); + let at = big.len() - 8; + big[at..at + 4].copy_from_slice(&0xffff_fffeu32.to_le_bytes()); + assert_eq!(code(&big), "wav-truncated"); } } diff --git a/wai/wai-pack/src/source/y4m.rs b/wai/wai-pack/src/source/y4m.rs index 2b29fc1c8..50d13c23e 100644 --- a/wai/wai-pack/src/source/y4m.rs +++ b/wai/wai-pack/src/source/y4m.rs @@ -142,12 +142,13 @@ impl Y4mReader { _ => {} } } + // The frame grows with the bytes read, never to the header's size ahead of them. let n = self.frame_bytes(); - self.buf.resize(n, 0); - self.r.read_exact(&mut self.buf).map_err(|e| match e.kind() { - std::io::ErrorKind::UnexpectedEof => SourceError::Refused(SourceReason::Y4mTruncated), - _ => SourceError::Io(e), - })?; + self.buf.clear(); + (&mut self.r).take(n as u64).read_to_end(&mut self.buf)?; + if self.buf.len() < n { + return refuse(SourceReason::Y4mTruncated); + } Ok(Some(to_rgb(&self.header, &self.buf))) } } diff --git a/wai/wai-pack/tests/source_alloc.rs b/wai/wai-pack/tests/source_alloc.rs new file mode 100644 index 000000000..3aa8e1ba2 --- /dev/null +++ b/wai/wai-pack/tests/source_alloc.rs @@ -0,0 +1,59 @@ +//! No field of a source sizes an allocation: a WAV `data` chunk that declares 4 GiB, and +//! a Y4M header that declares a 4096 × 4096 frame, each followed by a few bytes, are +//! refused having allocated about what they held. Its own binary: it installs a +//! counting global allocator. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Peak; +static LIVE: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Peak { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc(l) }; + if !p.is_null() { + let now = LIVE.fetch_add(l.size(), Ordering::SeqCst) + l.size(); + PEAK.fetch_max(now, Ordering::SeqCst); + } + p + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) }; + LIVE.fetch_sub(l.size(), Ordering::SeqCst); + } +} + +#[global_allocator] +static A: Peak = Peak; + +/// The most a reader may hold beyond what it was handed, for files of a few bytes. +const BOUND: usize = 1 << 20; + +fn peak_of(f: impl FnOnce() -> &'static str) -> (&'static str, usize) { + let base = LIVE.load(Ordering::SeqCst); + PEAK.store(base, Ordering::SeqCst); + let v = f(); + (v, PEAK.load(Ordering::SeqCst) - base) +} + +#[test] +fn a_declared_size_never_sizes_a_buffer() { + let mut wav = b"RIFF\0\0\0\0WAVEfmt \x10\0\0\0".to_vec(); + wav.extend_from_slice(&[1, 0, 1, 0]); // PCM, one channel + wav.extend_from_slice(&8000u32.to_le_bytes()); + wav.extend_from_slice(&16000u32.to_le_bytes()); + wav.extend_from_slice(&[2, 0, 16, 0]); // align 2, 16 bits + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&0xffff_fffeu32.to_le_bytes()); + wav.extend_from_slice(&[0; 4]); + let (v, peak) = peak_of(|| wai_pack::conformance::source_verdict("x.wav", &wav)); + assert_eq!(v, "wav-truncated"); + assert!(peak < BOUND, "a 4 GiB data claim allocated {peak} bytes"); + + let y4m = [&b"YUV4MPEG2 W4096 H4096 F25:1 C444\nFRAME\n"[..], &[16; 6]].concat(); + let (v, peak) = peak_of(|| wai_pack::conformance::source_verdict("x.y4m", &y4m)); + assert_eq!(v, "y4m-truncated"); + assert!(peak < BOUND, "a 4096 × 4096 frame header allocated {peak} bytes"); +} -- 2.54.0 (Apple Git-157) From 5032248af334dd30ca6c3df650a9d2409e69992f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:04:29 -0400 Subject: [PATCH 12/24] wai-pack: the WAV reader's own block bound is tested, whatever block a caller asks for Co-Authored-By: Claude Opus 5.5 --- wai/wai-pack/tests/source_alloc.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/wai/wai-pack/tests/source_alloc.rs b/wai/wai-pack/tests/source_alloc.rs index 3aa8e1ba2..592d765ed 100644 --- a/wai/wai-pack/tests/source_alloc.rs +++ b/wai/wai-pack/tests/source_alloc.rs @@ -51,6 +51,13 @@ fn a_declared_size_never_sizes_a_buffer() { let (v, peak) = peak_of(|| wai_pack::conformance::source_verdict("x.wav", &wav)); assert_eq!(v, "wav-truncated"); assert!(peak < BOUND, "a 4 GiB data claim allocated {peak} bytes"); + // The reader's own bound, whatever block a caller asks for. + let (v, peak) = peak_of(|| { + let mut r = wai_pack::source::wav::WavReader::new(&wav[..]).unwrap(); + r.next_block(u64::MAX).map(|_| "ok").unwrap_or_else(|e| e.code()) + }); + assert_eq!(v, "wav-truncated"); + assert!(peak < BOUND, "next_block(u64::MAX) on a 4 GiB data claim allocated {peak} bytes"); let y4m = [&b"YUV4MPEG2 W4096 H4096 F25:1 C444\nFRAME\n"[..], &[16; 6]].concat(); let (v, peak) = peak_of(|| wai_pack::conformance::source_verdict("x.y4m", &y4m)); -- 2.54.0 (Apple Git-157) From e864482332caa30327fa921040b07d669573cdc5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:09:57 -0400 Subject: [PATCH 13/24] wai-pack: a policy is read from its bytes: UTF-8, no byte-order mark, nesting capped at 32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round, item 2. Policy::parse_bytes reads a policy as bytes; the CLI and the corpus use it (no lossy decode). Before the value kinds, and before anything else, these are policy-toml in both implementations: - bytes that are not UTF-8; - a byte-order mark (the house rule: UTF-8 with no byte-order mark); - not TOML 1.0, an integer outside TOML's 64-bit range among it (pack_verify.py's reader takes any size, so it now checks the range); - arrays and tables nested past 32, the document's table at depth 1: a pass of its own before the value-kind walk, so a nesting fault outranks a float found earlier in key order, as the Rust parser's own nesting limit already made it. pack_verify.py also reads a RecursionError from its TOML reader as policy-toml. packaging §3.1 and §3.2 state it. Corpus: a byte-order mark, bytes that are not UTF-8, an integer at 2^63, nesting to depth 33 and 80. policy_diff.py (from the review's byte-level cases and seeded policies) compares every verdict and digest of wai-pack policy-verdict with pack_verify.py's; CI runs it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 8 +- wai/extensions/packaging.md | 12 +- wai/pack-conformance/README.md | 14 +- wai/pack-conformance/pack_verify.py | 58 ++++- wai/pack-conformance/policy_diff.py | 234 ++++++++++++++++++ wai/pack-conformance/refusals/expected.json | 20 ++ .../refusals/policy/toml-bom.toml | 15 ++ .../refusals/policy/toml-depth-33.toml | 16 ++ .../refusals/policy/toml-depth-80.toml | 16 ++ .../refusals/policy/toml-integer-64.toml | 16 ++ .../refusals/policy/toml-non-utf8.toml | 15 ++ wai/wai-pack/src/bin/wai_pack.rs | 19 +- wai/wai-pack/src/capcheck.rs | 8 +- wai/wai-pack/src/conformance.rs | 27 +- wai/wai-pack/src/policy.rs | 70 +++++- 15 files changed, 509 insertions(+), 39 deletions(-) create mode 100644 wai/pack-conformance/policy_diff.py create mode 100644 wai/pack-conformance/refusals/policy/toml-bom.toml create mode 100644 wai/pack-conformance/refusals/policy/toml-depth-33.toml create mode 100644 wai/pack-conformance/refusals/policy/toml-depth-80.toml create mode 100644 wai/pack-conformance/refusals/policy/toml-integer-64.toml create mode 100644 wai/pack-conformance/refusals/policy/toml-non-utf8.toml diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 24bc360fe..9d94dbec9 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -86,7 +86,7 @@ jobs: echo "$target: $n tests (floor $floor)" [ "$n" -ge "$floor" ] || { echo "::error::$target lists $n tests, under its floor of $floor"; fail=1; } done <<'EOF' - --lib 14 + --lib 15 --test=conformance 1 --test=scenecut 3 --test=source_alloc 1 @@ -133,6 +133,12 @@ jobs: for profile in debug release; do python3 ../pack-conformance/source_diff.py "target/$profile/wai-pack" 1 3000 done + # The policy reader differentially: byte-level cases and seeded random policies, + # every verdict and digest of wai-pack equal to pack_verify.py's. + - name: policy_diff.py, wai-pack against pack_verify.py + shell: bash + working-directory: wai/wai-pack + run: python3 ../pack-conformance/policy_diff.py target/release/wai-pack 1 3000 licences: name: wai-pack · dependency licences diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index 977f9bea5..04fb46223 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -33,7 +33,7 @@ where it names them. ## 3. Policy **3.1** A package is made under exactly one policy, a TOML 1.0 document of schema -`wai.pack.policy/1`. +`wai.pack.policy/1`, in UTF-8 with no byte-order mark. **3.2** The **effective policy** is the policy with every default written in (§3.3) and every override the packager applies (such as a command line forcing @@ -48,8 +48,12 @@ tables, dotted keys, string or integer notation, comments, an omitted default or default written out) therefore have one digest; the order of an array of tables is part of the policy. -A packager **MUST** refuse a policy that is not TOML 1.0 (`policy-toml`), and, before -it reads any key, one holding anywhere a float (`policy-float`), a date or time +A packager **MUST** refuse with `policy-toml`, before anything else, a policy whose +bytes are not UTF-8, that begins with a byte-order mark, that is not TOML 1.0 (an +integer outside TOML's 64-bit range included), or that nests arrays and tables more +than 32 deep: the document's own table is at depth 1, and an array or a table +directly inside one at depth *k* is at depth *k* + 1. Then, before it reads any key, it +**MUST** refuse one holding anywhere a float (`policy-float`), a date or time (`policy-datetime`), a negative integer (`policy-negative`), or an integer above `2^53 − 1` (`policy-integer-range`), visiting members in byte order of their keys and arrays in order. @@ -317,7 +321,7 @@ re-pack equivalence; they are specified with the packager stages that write them | Reason | Where | |---|---| -| `policy-toml`, `policy-float`, `policy-datetime`, `policy-negative`, `policy-integer-range` | §3.2 | +| `policy-toml` (not UTF-8, a byte-order mark, not TOML 1.0, nesting past 32), `policy-float`, `policy-datetime`, `policy-negative`, `policy-integer-range` | §3.2 | | `policy-unknown-key`, `policy-missing-key`, `policy-type`, `policy-value`, `policy-schema` | §3.3 | | `policy-too-many-components`, `policy-too-many-rungs`, `policy-too-many-classes`, `policy-too-many-rules`, `policy-too-many-moments`, `policy-duplicate-id`, `policy-lead`, `policy-open-gop`, `policy-piece-bounds`, `policy-ladder-component`, `policy-refines-role`, `policy-dangling-refines`, `policy-refines-cycle`, `policy-refines-base`, `policy-refines-branch`, `policy-no-base`, `policy-class-layer`, `policy-rule-ladder`, `policy-layer-lag`, `policy-too-many-deadlines`, `policy-deadline-unreachable` | §3.4 | | `capability-unknown`, `capability-not-payload`, `capability-candidate`, `capability-role`, `license-unclassified`, `license-refused`, each naming the capability (and the licence label) | §3.6 | diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index 0b55e4430..402a6764a 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -9,8 +9,10 @@ digest and what it may ask a packager to emit (§3), how a source is cut into pi pairs `eq--a.toml` and `eq--b.toml`, two spellings of one policy that must have one digest: every default omitted or written out; standard or inline tables, dotted keys, member order, string escapes, integer notation and comments. -- `refusals/` — `policy/*.toml`, one fault each, and `source/*` (Y4M and WAV), and - `expected.json`: each one's reason (§12). A policy's reason is a packager's: the +- `refusals/` — `policy/*.toml`, one fault each (the byte layer's among them: a + byte-order mark, bytes that are not UTF-8, an integer at 2^63, nesting to depth 33 and + 80), and `source/*` (Y4M and WAV), and `expected.json`: each one's reason (§12). Every + file is read as bytes. A policy's reason is a packager's: the policy's own (§3.2–§3.4), or its first rung's capability refusal (§3.6), read from SPEC §5's registry. The source files are written by the generator. - `conversions.json` and `conversions/` — milliseconds to a timebase (§4.4), a lead @@ -64,6 +66,14 @@ Files are compared as bytes; `.gitattributes` turns off line-ending conversion. python3 pack-conformance/pack_verify.py pack-conformance +- `policy_diff.py` runs the policy reader differentially: byte-level cases (a + byte-order mark, bytes that are not UTF-8, integers past 2^53 and 2^63, nesting at + and past the cap, control characters, line endings) and seeded random policies with + faults in every table, each one's verdict and digest from `wai-pack policy-verdict` + equal to `pack_verify.py`'s. + + python3 pack-conformance/policy_diff.py 1 3000 + - `source_diff.py` runs the source readers differentially: seeded Y4M and WAV mutations (dimensions and channel counts at and past their range, every format field, truncations, single-byte changes), each file's verdict from diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index eb4d5788d..df70321db 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -488,10 +488,41 @@ def constrain(P, lag): return P +MAX_DEPTH = 32 +I64 = (-(1 << 63), (1 << 63) - 1) + + +def shallow(v, depth): + """Every container in v, itself at depth `depth`, at most MAX_DEPTH deep (§3.2). The + recursion stops at the first container past it.""" + if isinstance(v, list): + return depth <= MAX_DEPTH and all(shallow(x, depth + 1) for x in v) + if isinstance(v, dict): + return depth <= MAX_DEPTH and all(shallow(x, depth + 1) for x in v.values()) + return True + + +def in_i64(v): + """TOML's integers are 64-bit (TOML 1.0); tomllib reads any size.""" + if is_int(v): + return I64[0] <= v <= I64[1] + if isinstance(v, list): + return all(in_i64(x) for x in v) + if isinstance(v, dict): + return all(in_i64(x) for x in v.values()) + return True + + def parse_policy(text): + """§3.2: not TOML 1.0, a byte-order mark, an integer outside 64 bits, or nesting past + MAX_DEPTH are `policy-toml`, before any value kind; then the three stages.""" + if text.startswith("\ufeff"): + raise Refused("policy-toml") try: doc = tomllib.loads(text) - except tomllib.TOMLDecodeError: + except (tomllib.TOMLDecodeError, RecursionError): + raise Refused("policy-toml") + if not all(shallow(v, 2) for v in doc.values()) or not in_i64(doc): raise Refused("policy-toml") for k in sorted(doc): kinds(doc[k], k) @@ -499,6 +530,17 @@ def parse_policy(text): return constrain(P, lag) +def parse_policy_bytes(b): + """A policy's bytes: UTF-8 with no byte-order mark (§3.2).""" + if b.startswith(b"\xef\xbb\xbf"): + raise Refused("policy-toml") + try: + text = b.decode("utf-8") + except UnicodeDecodeError: + raise Refused("policy-toml") + return parse_policy(text) + + def digest(P): return blake3(b"wai:pack-policy\x01" + jcs(P).encode()).hex() @@ -527,9 +569,9 @@ def rung_refusal(cap, role, royalty_free_only): return None -def admit(text): - """A packager's verdict: the policy's refusal, or the first rung's.""" - P = parse_policy(text) +def admit(b): + """A packager's verdict on a policy's bytes: the policy's refusal, or the first rung's.""" + P = parse_policy_bytes(b) for r in P["ladder"]: code = rung_refusal(r["capability"], r["role"], P["royalty_free_only"]) if code: @@ -877,7 +919,7 @@ def verify(d): rep = Report() pol = json.loads(load(os.path.join(d, "policy", "expected.json"))) for c in pol["cases"]: - P = parse_policy(load(os.path.join(d, "policy", c["file"])).decode()) + P = parse_policy_bytes(load(os.path.join(d, "policy", c["file"]))) if c.get("overrides", {}).get("royalty_free_only") is not None: P["royalty_free_only"] = c["overrides"]["royalty_free_only"] rep.check(jcs(P) == c["canonical"], f"policy/{c['file']}: canonical JSON") @@ -885,7 +927,7 @@ def verify(d): names = sorted(n for n in os.listdir(os.path.join(d, "policy")) if n.endswith(".toml")) rep.check(sorted({c["file"] for c in pol["cases"]}) == names, "policy/: every policy is a case") for a, b in pol["equal"]: - da, db = (digest(parse_policy(load(os.path.join(d, "policy", f)).decode())) for f in (a, b)) + da, db = (digest(parse_policy_bytes(load(os.path.join(d, "policy", f)))) for f in (a, b)) rep.check(da == db, f"policy/{a} and {b}: one policy, one digest") ref = json.loads(load(os.path.join(d, "refusals", "expected.json"))) @@ -895,7 +937,7 @@ def verify(d): b = load(os.path.join(d, "refusals", c["file"])) if c["file"].startswith("policy/"): try: - admit(b.decode()) + admit(b) got = "ok" except Refused as r: got = r.code @@ -916,7 +958,7 @@ def verify(d): rep.check([hashlib.sha256(f).hexdigest() for f in frames] == c["rgb_sha256"], f"{c['file']}: RGB8 frames") sc = json.loads(load(os.path.join(d, "scenecut", "expected.json"))) - P = parse_policy(load(os.path.join(d, "scenecut", sc["policy"])).decode()) + P = parse_policy_bytes(load(os.path.join(d, "scenecut", sc["policy"]))) comp = next(c for c in P["component"] if c["id"] == sc["component"]) tb = (comp["timebase"]["num"], comp["timebase"]["den"]) s = P["segmentation"] diff --git a/wai/pack-conformance/policy_diff.py b/wai/pack-conformance/policy_diff.py new file mode 100644 index 000000000..c0dc9c0f1 --- /dev/null +++ b/wai/pack-conformance/policy_diff.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""The policy reader, differentially: wai-pack against pack_verify.py. + +Two sets of policies, each file's verdict (`ok`, or the reason a packager refuses it) +and, when the policy itself parses, its digest, from `wai-pack policy-verdict`, must +equal pack_verify.py's: + +- byte-level cases: a byte-order mark, bytes that are not UTF-8, integers at and past + 2^53 and 2^63, nesting at and past the cap in arrays, inline tables and dotted keys, + control characters, line endings, NUL, escapes, ids at the byte limit, one id named + twice across kinds; +- seeded random policies with random faults in every table (from a fixed seed). + + python3 policy_diff.py [seed] [count] + +Exit status 0 only if every verdict and digest agrees. Python 3.11 or later (tomllib). +""" +import os +import random +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import pack_verify as PV # noqa: E402 + +MIN = '''schema = "wai.pack.policy/1" +asset_id = "a" +lead = "v" +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" +''' + + +def byte_cases(): + c = {} + + def case(name, b): + c[name] = b.encode() if isinstance(b, str) else b + case("min", MIN) + case("bom", b"\xef\xbb\xbf" + MIN.encode()) + case("int-2^53", MIN + "zzz = 9007199254740992\n") + case("int-2^63", MIN + "zzz = 9223372036854775808\n") + case("int-2^64", MIN + "zzz = 18446744073709551616\n") + case("int-i64max", MIN + "zzz = 9223372036854775807\n") + case("int-i64min", MIN + "zzz = -9223372036854775808\n") + case("hex-int", MIN + "[segmentation]\ntarget_ms = 0x7d0\n") + case("nan", MIN + "zzz = nan\n") + case("neg-zero", MIN + "[segmentation]\ncut_window = -0\n") + case("plus", MIN + "[segmentation]\ncut_window = +8\n") + case("non-utf8", MIN.encode() + b"# \xff\xfe comment\n") + case("non-utf8-str", MIN.replace('asset_id = "a"', 'asset_id = "a\xff"').encode("latin-1")) + case("surrogate-utf8", MIN.encode() + b"# \xed\xa0\x80\n") + for d in (31, 32, 33, 60, 79, 80, 200, 3000): + case(f"deep-array-{d}", "zzz = " + "[" * d + "]" * d + "\n" + MIN) + for d in (31, 32, 200): + case(f"deep-inline-{d}", "zzz = " + "{a=" * d + "1" + "}" * d + "\n" + MIN) + for d in (31, 32, 2000): + case(f"deep-dotted-{d}", "zzz." + ".".join(["a"] * d) + " = 1\n" + MIN) + case("deep-after-float", "aaa = 1.5\nzzz = " + "[" * 40 + "]" * 40 + "\n" + MIN) + case("dup-key", MIN + "asset_id = \"b\"\n") + case("ctrl-del", MIN.replace('asset_id = "a"', 'asset_id = "a\\u007f\\u001f\\b\\f"')) + case("u2028", MIN.replace('asset_id = "a"', 'asset_id = "a\\u2028\\u2029"')) + case("astral", MIN.replace('asset_id = "a"', 'asset_id = "\\U0001F600"')) + case("id-255-bytes", MIN.replace('asset_id = "a"', 'asset_id = "' + "é" * 127 + 'a"')) + case("id-256-bytes", MIN.replace('asset_id = "a"', 'asset_id = "' + "é" * 128 + '"')) + case("crlf", MIN.replace("\n", "\r\n")) + case("lone-cr", MIN.replace("\n", "\r")) + case("same-id-across-kinds", MIN.replace('id = "b0"', 'id = "v"')) + case("tab-in-literal", MIN.replace('asset_id = "a"', "asset_id = 'a\tb'")) + case("float-exp", MIN + "zzz = 1e3\n") + case("local-time", MIN + "zzz = 07:32:00\n") + case("int-refine-base", MIN.replace("wai.video.int_motion", "wai.video.int_refine")) + case("empty", b"") + case("nul", MIN.encode() + b"\x00") + case("u0000-escape", MIN.replace('asset_id = "a"', 'asset_id = "a\\u0000"')) + case("key-unicode", MIN + '"é" = 1\n') + case("ladder-empty", MIN.split("[[ladder]]")[0] + "ladder = []\n") + case("mixed-array", MIN + 'zzz = [1, "a"]\n') + case("params-wrong-type", MIN + "params = 5\n") + case("cut-window-2", MIN + "[segmentation]\ncut_window = 2\n") + return c + + +CAPS = ["wai.video.int_motion", "wai.video.int_refine", "wai.video.av1", "wai.audio.flac", "wai.image.png", + "wai.audio.int_refine", "wai.audio.int_layered", "wai.splat.sog", "wai.video.avc", "wai.video.package", + "wai.meta.hdr10plus", "wai.nope"] +PARAMS = ['{}', '{ block = 8 }', '{ block = 5 }', '{ search = 33 }', '{ q = 6 }', '{ rate = 8000 }', + '{ q_candidates = [] }', '{ q_candidates = [16, 12] }', '{ source = "encode" }', '{ bogus = 1 }', + '{ to_rate = 48000, q = 64 }'] +KINDS = ['"static"', '"bogus"', ''] + + +def random_policy(rng): + def biased(seq): + seq = list(seq) + return seq[0] if (rng.random() < 0.85 and seq) else rng.choice(seq) + + def val(kind): + if rng.random() < 0.01: + return biased(['1.5', '-3', '9007199254740992', '"x"', 'true', '[]', '{}']) + return kind + + q = '"' + L = [] + if rng.random() > 0.03: + L.append("schema = " + val(q + "wai.pack.policy/1" + q)) + if rng.random() > 0.03: + L.append("asset_id = " + val(q + biased(["a", "", "x" * 256]) + q)) + if rng.random() < 0.5: + L.append("royalty_free_only = " + val(biased(["true", "false"]))) + comps = [rng.choice(["v", "a", "v2", "v"]) for _ in range(biased([1, 2, 0, 3]))] + lead = rng.choice(comps if rng.random() < 0.95 else comps + ["zz"]) if comps else "v" + L.append(f'lead = "{lead}"') + if rng.random() < 0.05: + L.append("zz_unknown = 1") + body = [] + for c in comps: + body += ["[[component]]", f'id = "{c}"', "media = " + val(q + biased(["video", "audio", "bogus"]) + q)] + if rng.random() > 0.05: + num, den = val(str(biased([25, 0, 48000]))), val(str(biased([1, 1001, 0]))) + body.append("timebase = { num = " + num + ", den = " + den + " }") + if rng.random() < 0.6: + body.append("[segmentation]") + for k, choices in [("target_ms", [2000, 500, 0]), ("min_ms", [1000, 3000, 0]), ("max_ms", [4000, 100]), + ("cut_abs_permille", [300, 0, 1001]), ("cut_window", [8, 0, 2, 3, 65]), + ("lookahead_frames", [6, 65]), ("closed", ["true", "false"])]: + if rng.random() < 0.4: + body.append(f"{k} = " + val(str(biased(choices)))) + rungs = [] + for i in range(biased([2, 1, 3, 4, 0])): + rid = biased([f"r{i}", "r0", "b", "v"]) + role = rng.choice(["base", "refinement", "base", "floor", "bogus"]) + rungs.append(rid) + comp = rng.choice(comps if rng.random() < 0.95 else comps + ["zz"]) if comps else "v" + cap = rng.choice(CAPS[:5] if rng.random() < 0.8 else CAPS) + body += ["[[ladder]]", f'id = "{rid}"', f'component = "{comp}"', f'capability = "{cap}"', f'role = "{role}"'] + if role == "refinement" or rng.random() < 0.1: + body.append(f'refines = "{rng.choice(rungs + ["nope"])}"') + if rng.random() < 0.5: + body.append("params = " + biased(PARAMS)) + if rng.random() < 0.3: + body += ["[live]", "layer_lag_ms = " + biased(["[0]", "[0, 200]", "[0, 200, 400]", "[1]", "[0, 100, 50]"])] + for i in range(biased([0, 0, 1, 2])): + body += ["[[class]]", f'id = "{biased(["c" + str(i), "c0"])}"'] + if rng.random() < 0.5: + body.append(f"max_layer = {biased([0, 1, 2, 3])}") + if rng.random() < 0.3: + body.append(f"max_kbps = {biased([1, 0, 900])}") + for i in range(biased([0, 0, 1, 2])): + body += ["[[moment]]", f'id = "m{i}"', f"from_ms = {biased([0, 100])}", f"to_ms = {biased([0, 100, 200])}"] + if rng.random() < 0.5: + body.append('tags = ["event"]') + for i in range(biased([0, 0, 1, 2, 5])): + body += ["[[rule]]", f'id = "{biased(["u" + str(i), "u0"])}"'] + if rng.random() < 0.5: + body.append("when = { piece_kind = [" + biased(KINDS) + "] }") + t = [] + if rng.random() < 0.4: + pick = rng.sample(rungs + ["nope"], k=min(2, len(rungs) + 1)) + t.append("ladder = [" + ", ".join(q + x + q for x in pick) + "]") + if rng.random() < 0.4: + t.append(f"max_layer = {biased([0, 1, 2])}") + if rng.random() < 0.4: + t.append(f"deadline_ms = {biased([1, 100, 200, 300, 400, 800])}") + if t: + body.append("then = { " + ", ".join(t) + " }") + if rng.random() < 0.3: + body += ["[defaults]", f"deadline_ms = {biased([1500, 1, 0, 150])}"] + if rng.random() < 0.2: + body += ["[classify]", f"synthetic_bins = {biased([12, 65])}"] + return ("\n".join(L + body) + "\n").encode() + + +def python_verdict(b): + try: + P = PV.parse_policy_bytes(b) + except PV.Refused as e: + return e.code, "-" + try: + PV.admit(b) + v = "ok" + except PV.Refused as e: + v = e.code + return v, PV.digest(P) + + +def main(argv): + if len(argv) < 2: + print(__doc__) + return 2 + binary = argv[1] + seed = int(argv[2]) if len(argv) > 2 else 1 + count = int(argv[3]) if len(argv) > 3 else 3000 + rng = random.Random(seed) + cases = list(byte_cases().items()) + [(f"random-{i}", random_policy(rng)) for i in range(count)] + bad = 0 + with tempfile.TemporaryDirectory() as d: + paths = {} + for name, b in cases: + p = os.path.join(d, name + ".toml") + with open(p, "wb") as f: + f.write(b) + paths[p] = (name, b) + rust = {} + files = list(paths) + for k in range(0, len(files), 500): + r = subprocess.run([binary, "policy-verdict"] + files[k:k + 500], capture_output=True) + if r.returncode != 0: + print(r.stderr.decode(errors="replace")[-2000:]) + return 1 + for line in r.stdout.decode().splitlines(): + p, v, dg = line.split("\t") + rust[p] = (v, dg) + for p, (name, b) in paths.items(): + py = python_verdict(b) + if rust.get(p) != py: + bad += 1 + if bad <= 10: + print(f"DIFF {name}: wai-pack {rust.get(p)}, pack_verify.py {py}") + print(f"policy_diff: seed {seed}, {len(cases)} policies, {bad} differences") + return 0 if bad == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/wai/pack-conformance/refusals/expected.json b/wai/pack-conformance/refusals/expected.json index e02a21c4a..64781875e 100644 --- a/wai/pack-conformance/refusals/expected.json +++ b/wai/pack-conformance/refusals/expected.json @@ -104,10 +104,30 @@ "file": "policy/schema.toml", "reason": "policy-schema" }, + { + "file": "policy/toml-bom.toml", + "reason": "policy-toml" + }, + { + "file": "policy/toml-depth-33.toml", + "reason": "policy-toml" + }, + { + "file": "policy/toml-depth-80.toml", + "reason": "policy-toml" + }, { "file": "policy/toml-duplicate-key.toml", "reason": "policy-toml" }, + { + "file": "policy/toml-integer-64.toml", + "reason": "policy-toml" + }, + { + "file": "policy/toml-non-utf8.toml", + "reason": "policy-toml" + }, { "file": "policy/toml.toml", "reason": "policy-toml" diff --git a/wai/pack-conformance/refusals/policy/toml-bom.toml b/wai/pack-conformance/refusals/policy/toml-bom.toml new file mode 100644 index 000000000..cf49c98cb --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml-bom.toml @@ -0,0 +1,15 @@ +# Refused: a byte-order mark before the document. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/toml-depth-33.toml b/wai/pack-conformance/refusals/policy/toml-depth-33.toml new file mode 100644 index 000000000..be2358f50 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml-depth-33.toml @@ -0,0 +1,16 @@ +# Refused: arrays nested to depth 33 (the document's table is depth 1). +zzz = [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/toml-depth-80.toml b/wai/pack-conformance/refusals/policy/toml-depth-80.toml new file mode 100644 index 000000000..0989938c0 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml-depth-80.toml @@ -0,0 +1,16 @@ +# Refused: arrays nested to depth 80 (the document's table is depth 1). +zzz = [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]] +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/toml-integer-64.toml b/wai/pack-conformance/refusals/policy/toml-integer-64.toml new file mode 100644 index 000000000..1e34075ee --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml-integer-64.toml @@ -0,0 +1,16 @@ +# Refused: an integer outside TOML's 64-bit range (2^63). +zzz = 9223372036854775808 +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/pack-conformance/refusals/policy/toml-non-utf8.toml b/wai/pack-conformance/refusals/policy/toml-non-utf8.toml new file mode 100644 index 000000000..e5a8709e3 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/toml-non-utf8.toml @@ -0,0 +1,15 @@ +# Refused: bytes that are not UTF-8 (in this comment: ÿþ). +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/wai-pack/src/bin/wai_pack.rs b/wai/wai-pack/src/bin/wai_pack.rs index 27fcb25a9..2a4024c82 100644 --- a/wai/wai-pack/src/bin/wai_pack.rs +++ b/wai/wai-pack/src/bin/wai_pack.rs @@ -7,6 +7,9 @@ //! wai-pack plan --policy --component [--source ] [--out ] //! segment a video component with wai.pack.scenecut/1: one piece per line //! (start, frames, flags, kind), then the plan digest +//! wai-pack policy-verdict ... +//! each policy's verdict (`ok`, or the reason a packager refuses it) and, when the +//! policy itself parses, its digest (else `-`): `\t\t` //! wai-pack source-verdict ... //! each source's verdict (`ok`, or the reason it is refused), one `\t` //! line each; a reader that panics prints `PANIC` @@ -30,6 +33,7 @@ fn usage() -> ExitCode { eprintln!( "usage:\n wai-pack policy [--royalty-free-only]\n \ wai-pack plan --policy --component [--source ] [--out ]\n \ + wai-pack policy-verdict ...\n \ wai-pack source-verdict ...\n \ wai-pack fixture gen [-o ] | fixture list\n \ wai-pack conformance regen|check " @@ -38,8 +42,8 @@ fn usage() -> ExitCode { } fn load_policy(path: &str) -> Result { - let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; - Policy::parse(&text).map_err(|e| e.to_string()) + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + Policy::parse_bytes(&bytes).map_err(|e| e.to_string()) } fn cmd_policy(args: &[String]) -> Result<(), String> { @@ -92,6 +96,16 @@ fn cmd_plan(args: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_policy_verdict(args: &[String]) -> Result<(), String> { + for p in args { + let b = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?; + let v = conformance::policy_verdict(&b); + let d = Policy::parse_bytes(&b).map(|x| hex(&x.digest())).unwrap_or_else(|_| "-".into()); + println!("{p}\t{v}\t{d}"); + } + Ok(()) +} + fn cmd_source_verdict(args: &[String]) -> Result<(), String> { for p in args { let b = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?; @@ -151,6 +165,7 @@ fn main() -> ExitCode { let r = match cmd.as_str() { "policy" => cmd_policy(rest), "plan" => cmd_plan(rest), + "policy-verdict" => cmd_policy_verdict(rest), "source-verdict" => cmd_source_verdict(rest), "fixture" => cmd_fixture(rest), "conformance" => cmd_conformance(rest), diff --git a/wai/wai-pack/src/capcheck.rs b/wai/wai-pack/src/capcheck.rs index 8586ddad1..5fb93bd43 100644 --- a/wai/wai-pack/src/capcheck.rs +++ b/wai/wai-pack/src/capcheck.rs @@ -83,10 +83,10 @@ impl Refused { } } -/// A policy a packager accepts: parsed, checked, with the overrides applied, and every -/// rung emittable. -pub fn admit(text: &str, o: &crate::policy::Overrides) -> Result { - let p = Policy::parse(text).map_err(Refused::Policy)?.effective(o); +/// A policy a packager accepts, from its bytes: parsed, checked, with the overrides +/// applied, and every rung emittable. +pub fn admit(bytes: &[u8], o: &crate::policy::Overrides) -> Result { + let p = Policy::parse_bytes(bytes).map_err(Refused::Policy)?.effective(o); check(&p).map_err(Refused::Capability)?; Ok(p) } diff --git a/wai/wai-pack/src/conformance.rs b/wai/wai-pack/src/conformance.rs index 5fe0255c6..3b4b4140c 100644 --- a/wai/wai-pack/src/conformance.rs +++ b/wai/wai-pack/src/conformance.rs @@ -72,8 +72,7 @@ fn policy_expected(dir: &Path) -> std::io::Result { let names = tomls(dir)?; let mut cases = Vec::new(); for (name, o) in policy_cases(&names) { - let text = String::from_utf8_lossy(&read(&dir.join(&name))?).into_owned(); - let p = Policy::parse(&text) + let p = Policy::parse_bytes(&read(&dir.join(&name))?) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{name}: {e}")))? .effective(&o); let mut c = json!({"file": name, "canonical": p.canonical_json(), "digest": hex(&p.digest())}); @@ -153,17 +152,17 @@ pub fn source_verdict(name: &str, bytes: &[u8]) -> &'static str { } } -/// A packager's verdict on a policy: `ok`, or the reason it refuses it (the policy's, -/// or the first rung's). -pub fn policy_verdict(text: &str) -> String { - crate::capcheck::admit(text, &Overrides::default()).map(|_| "ok".to_string()).unwrap_or_else(|e| e.code()) +/// A packager's verdict on a policy's bytes: `ok`, or the reason it refuses it (the +/// policy's, or the first rung's). +pub fn policy_verdict(bytes: &[u8]) -> String { + crate::capcheck::admit(bytes, &Overrides::default()).map(|_| "ok".to_string()).unwrap_or_else(|e| e.code()) } fn refusals_expected(dir: &Path) -> std::io::Result { let mut cases = Vec::new(); for name in tomls(&dir.join("policy"))? { - let text = String::from_utf8_lossy(&read(&dir.join("policy").join(&name))?).into_owned(); - cases.push(json!({"file": format!("policy/{name}"), "reason": policy_verdict(&text)})); + let bytes = read(&dir.join("policy").join(&name))?; + cases.push(json!({"file": format!("policy/{name}"), "reason": policy_verdict(&bytes)})); } for (name, bytes) in source_refusals() { cases.push(json!({"file": format!("source/{name}"), "reason": source_verdict(name, &bytes)})); @@ -248,8 +247,8 @@ fn conversions_expected() -> Value { // ─────────────────────────────── scene cut ─────────────────────────────── /// The scene-cut vectors of every fixture under the policy at `policy`, component `v`. -pub fn scenecut_expected(policy_text: &str) -> Result { - let p = Policy::parse(policy_text).map_err(|e| e.to_string())?; +pub fn scenecut_expected(policy: &[u8]) -> Result { + let p = Policy::parse_bytes(policy).map_err(|e| e.to_string())?; let c = p.component("v").ok_or("the scene-cut policy has no component v")?; let sp = SegParams::from_policy(&p.segmentation, c.timebase); let mut out = Vec::new(); @@ -300,8 +299,8 @@ pub struct EncodeVector { /// Every encode vector: each piece of each fixture (its first [`ENCODE_FRAMES`] /// frames, cut by the scene-cut policy), under each of [`ENCODE_SETS`]. -pub fn encode_vectors(scenecut_policy: &str) -> Result, String> { - let p = Policy::parse(scenecut_policy).map_err(|e| e.to_string())?; +pub fn encode_vectors(scenecut_policy: &[u8]) -> Result, String> { + let p = Policy::parse_bytes(scenecut_policy).map_err(|e| e.to_string())?; let c = p.component("v").ok_or("the scene-cut policy has no component v")?; let sp = SegParams::from_policy(&p.segmentation, c.timebase); let mut out = Vec::new(); @@ -366,7 +365,7 @@ pub fn regen(dir: &Path) -> std::io::Result<()> { std::fs::write(dir.join("conversions").join(name), bytes)?; } write_json(&dir.join("conversions.json"), &conversions_expected())?; - let text = String::from_utf8_lossy(&read(&dir.join("scenecut/policy.toml"))?).into_owned(); + let text = read(&dir.join("scenecut/policy.toml"))?; let sc = scenecut_expected(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; write_json(&dir.join("scenecut/expected.json"), &sc)?; let vectors = encode_vectors(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; @@ -420,7 +419,7 @@ pub fn check(dir: &Path) -> std::io::Result> { } } same("conversions.json", &read_json(&dir.join("conversions.json"))?, &conversions_expected(), &mut out); - let text = String::from_utf8_lossy(&read(&dir.join("scenecut/policy.toml"))?).into_owned(); + let text = read(&dir.join("scenecut/policy.toml"))?; match scenecut_expected(&text) { Ok(sc) => same("scenecut/expected.json", &read_json(&dir.join("scenecut/expected.json"))?, &sc, &mut out), Err(e) => out.push(format!("scenecut/policy.toml: {e}")), diff --git a/wai/wai-pack/src/policy.rs b/wai/wai-pack/src/policy.rs index 9df91dfcb..4320cf17a 100644 --- a/wai/wai-pack/src/policy.rs +++ b/wai/wai-pack/src/policy.rs @@ -1,8 +1,13 @@ //! The packaging policy, schema `wai.pack.policy/1` ([packaging] §3). //! -//! A package is made under one policy, a TOML 1.0 document. [`Policy::parse`] reads it -//! in three stages, and the first fault it meets is the refusal: +//! A package is made under one policy, a TOML 1.0 document in UTF-8 with no +//! byte-order mark. [`Policy::parse_bytes`] reads it in three stages, and the first fault +//! it meets is the refusal: //! +//! 0. **Bytes.** Bytes that are not UTF-8, a byte-order mark, a document that is not +//! TOML 1.0 (an integer outside TOML's 64-bit range among them), and arrays and +//! tables nested more than [`MAX_DEPTH`] deep are all `policy-toml`, whichever comes +//! first. //! 1. **Value kinds.** Anything canonical JSON cannot carry exactly is refused before //! any key is read: a float, a date or time, a negative integer, an integer past //! `2^53 − 1`. The walk visits members in byte order of their keys and arrays in @@ -35,6 +40,11 @@ pub const DOMAIN_POLICY: &[u8] = b"wai:pack-policy\x01"; pub const SCENECUT_V1: &str = "wai.pack.scenecut/1"; /// The largest integer a policy may hold: canonical JSON's exact-integer bound. pub const MAX_INTEGER: u64 = wai::jcs::MAX_INTEGER; +/// The deepest a policy nests arrays and tables: the document's own table is at depth +/// 1, and a container directly inside one at depth `k` is at depth `k + 1`. +pub const MAX_DEPTH: usize = 32; +/// UTF-8's byte-order mark, which a policy must not begin with. +const BOM: &[u8] = b"\xef\xbb\xbf"; /// The most components, rungs, classes, rules and moments a policy may hold, and the /// most distinct deadline values. @@ -51,7 +61,9 @@ pub const MAX_DEADLINES: usize = 4; /// [packaging]: ../../../extensions/packaging.md #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PolicyReason { - /// Not a TOML 1.0 document (a syntax error, a key defined twice). + /// Not a policy's bytes: not UTF-8, a byte-order mark, not a TOML 1.0 document (a + /// syntax error, a key defined twice, an integer outside 64 bits), or arrays and + /// tables nested more than [`MAX_DEPTH`] deep. Toml, /// A float anywhere in the document. Float, @@ -395,6 +407,18 @@ pub struct Policy { pub classify: ClassifyParams, } +// ─────────────────────────────── stage 0: nesting ─────────────────────────────── + +/// Whether every container in `v`, at depth `depth`, is at most [`MAX_DEPTH`] deep. The +/// recursion stops at the first container past it. +fn shallow(v: &toml::Value, depth: usize) -> bool { + match v { + toml::Value::Array(a) => depth <= MAX_DEPTH && a.iter().all(|x| shallow(x, depth + 1)), + toml::Value::Table(t) => depth <= MAX_DEPTH && t.values().all(|x| shallow(x, depth + 1)), + _ => true, + } +} + // ─────────────────────────────── stage 1: value kinds ─────────────────────────────── fn value_kinds(v: &toml::Value, at: &str) -> R<()> { @@ -813,13 +837,27 @@ impl Policy { /// /// [packaging]: ../../../extensions/packaging.md pub fn parse(text: &str) -> Result { - let doc: toml::Table = text.parse().map_err(|_| PolicyError::new(PolicyReason::Toml, ""))?; + let toml = || PolicyError::new(PolicyReason::Toml, ""); + if text.as_bytes().starts_with(BOM) { + return Err(toml()); + } + let doc: toml::Table = text.parse().map_err(|_| toml())?; + if !doc.values().all(|v| shallow(v, 2)) { + return Err(toml()); + } table_kinds(&doc, "")?; let (mut p, lag) = read(&doc)?; p.constrain(lag)?; Ok(p) } + /// [`Policy::parse`] over a policy's bytes: bytes that are not UTF-8 are not a policy + /// (`policy-toml`), and neither is a byte-order mark. + pub fn parse_bytes(b: &[u8]) -> Result { + let text = std::str::from_utf8(b).map_err(|_| PolicyError::new(PolicyReason::Toml, ""))?; + Policy::parse(text) + } + /// The effective policy: this one with `o` applied. pub fn effective(&self, o: &Overrides) -> Policy { let mut p = self.clone(); @@ -1183,6 +1221,30 @@ role = "base" assert_eq!(code("schema = "), "policy-toml"); } + /// The bytes before TOML: not UTF-8, a byte-order mark, an integer outside 64 bits, + /// nesting past 32 (the document's table at 1), all `policy-toml`, and before any + /// value kind. + #[test] + fn the_byte_layer() { + let b = |x: &[u8]| Policy::parse_bytes(x).map(|_| "ok").unwrap_or_else(|e| e.code()); + assert_eq!(b(MIN.as_bytes()), "ok"); + assert_eq!(b(&[b"\xef\xbb\xbf", MIN.as_bytes()].concat()), "policy-toml"); + assert_eq!(b(&[MIN.as_bytes(), b"# \xff\n"].concat()), "policy-toml"); + assert_eq!(b(format!("{MIN}zzz = 9223372036854775808\n").as_bytes()), "policy-toml"); + // The deep value comes first, in the document's own table (depth 1): `zzz` is at + // depth 2 and each array inside it one deeper. + let nest = |d: usize| format!("zzz = {}{}\n{MIN}", "[".repeat(d), "]".repeat(d)); + assert_eq!(b(nest(31).as_bytes()), "policy-unknown-key"); + assert_eq!(b(nest(32).as_bytes()), "policy-toml"); + assert_eq!(b(nest(79).as_bytes()), "policy-toml"); + // `zzz.a.….a = 1` with d tables: the deepest at depth d + 1. + let dotted = |d: usize| format!("zzz.{} = 1\n{MIN}", vec!["a"; d].join(".")); + assert_eq!(b(dotted(31).as_bytes()), "policy-unknown-key"); + assert_eq!(b(dotted(32).as_bytes()), "policy-toml"); + // Nesting outranks a float found earlier in key order. + assert_eq!(b(format!("aaa = 1.5\nzzz = {}{}\n{MIN}", "[".repeat(32), "]".repeat(32)).as_bytes()), "policy-toml"); + } + #[test] fn units_and_alignment_round_down() { let tb = Timebase { num: 30000, den: 1001 }; -- 2.54.0 (Apple Git-157) From f68d1b7ff13366cd6b2e5cb45a1d9ab79a350f68 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:12:42 -0400 Subject: [PATCH 14/24] wai-pack: the example policy is one a packager accepts; int_layered is reserved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round, item 6. The example named wai.audio.int_layered, which SPEC §5 does not register yet, so a packager refused it (capability-unknown). The audio component's base is now wai.audio.flac, and the rungs that named or refined int_layered are gone. int_layered's parameters leave the schema and §3.3's table, with a note that the string is reserved until it is registered. Both implementations now require every policy of the policy corpus to be accepted, rungs included, not only parsed. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/packaging.md | 4 +++- wai/pack-conformance/README.md | 3 ++- wai/pack-conformance/pack_verify.py | 8 +++++++- wai/pack-conformance/policy/example.toml | 22 ++++------------------ wai/pack-conformance/policy/expected.json | 8 ++++---- wai/wai-pack/src/conformance.rs | 14 +++++++++++--- wai/wai-pack/src/policy.rs | 1 - 7 files changed, 31 insertions(+), 29 deletions(-) diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index 04fb46223..d9b79e87b 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -127,10 +127,12 @@ A rung's `params` are those of its capability; every other capability takes none | | `max_sse_px_milli` | 0 | | | `wai.video.int_refine` | `q` | required | 1 to 255 | | `wai.video.av1`, `wai.video.av1.lossless` | `source` | `supplied` | `supplied`, `encode` | -| `wai.audio.int_layered` | `rate` | required | 1 to 384000 | | `wai.audio.int_refine` | `to_rate`, `q` | required | 1 to 384000; 1 to 65535 | | `wai.audio.flac` | `source` | `encode` | `encode`, `supplied` | +`wai.audio.int_layered` is reserved for the layered integer audio base: it is not +registered yet, and its parameters are defined when it is. + **3.4** *Constraints.* A policy that passes §3.3 is checked in this order, and the first that fails is the refusal: diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index 402a6764a..e416d58e8 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -4,7 +4,8 @@ Vectors for [`extensions/packaging.md`](../extensions/packaging.md): the policy, digest and what it may ask a packager to emit (§3), how a source is cut into pieces (§4), and the integer video payloads a packager writes (§5.5). -- `policy/` — policies and `expected.json`: each policy's effective canonical JSON and +- `policy/` — policies a packager accepts (every rung one it may emit, §3.6), and + `expected.json`: each policy's effective canonical JSON and digest (`example.toml` also with `royalty_free_only` overridden to false), and the pairs `eq--a.toml` and `eq--b.toml`, two spellings of one policy that must have one digest: every default omitted or written out; standard or inline diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index df70321db..e7bab520b 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -214,7 +214,6 @@ PARAMS = { "wai.video.int_refine": [("q", ("int", 1, 255, None))], "wai.video.av1": [("source", ("word", ["supplied", "encode"], "supplied"))], "wai.video.av1.lossless": [("source", ("word", ["supplied", "encode"], "supplied"))], - "wai.audio.int_layered": [("rate", ("int", 1, 384000, None))], "wai.audio.int_refine": [("to_rate", ("int", 1, 384000, None)), ("q", ("int", 1, 65535, None))], "wai.audio.flac": [("source", ("word", ["encode", "supplied"], "encode"))], } @@ -926,6 +925,13 @@ def verify(d): rep.check(digest(P) == c["digest"], f"policy/{c['file']}: digest") names = sorted(n for n in os.listdir(os.path.join(d, "policy")) if n.endswith(".toml")) rep.check(sorted({c["file"] for c in pol["cases"]}) == names, "policy/: every policy is a case") + for n in names: + try: + admit(load(os.path.join(d, "policy", n))) + got = "ok" + except Refused as r: + got = r.code + rep.check(got == "ok", f"policy/{n}: a packager accepts it ({got})") for a, b in pol["equal"]: da, db = (digest(parse_policy_bytes(load(os.path.join(d, "policy", f)))) for f in (a, b)) rep.check(da == db, f"policy/{a} and {b}: one policy, one digest") diff --git a/wai/pack-conformance/policy/example.toml b/wai/pack-conformance/policy/example.toml index 4b6276a8c..88829e747 100644 --- a/wai/pack-conformance/policy/example.toml +++ b/wai/pack-conformance/policy/example.toml @@ -1,6 +1,7 @@ # The example policy of extensions/packaging.md §3.3: two components (video lead, -# audio), a staged video ladder with a still-image rung and a supplied floor, a -# layered audio ladder, three classes, one moment and three rules. +# audio), a staged video ladder with a still-image rung and a supplied floor, an audio +# base, three classes, one moment and three rules. Every rung is one a packager may +# emit (§3.6). schema = "wai.pack.policy/1" asset_id = "pack-fixture-01" royalty_free_only = true @@ -75,23 +76,8 @@ params = { source = "supplied" } [[ladder]] id = "ab" component = "a" -capability = "wai.audio.int_layered" -role = "base" -params = { rate = 8000 } - -[[ladder]] -id = "ar1" -component = "a" -capability = "wai.audio.int_refine" -role = "refinement" -refines = "ab" -params = { to_rate = 48000, q = 64 } - -[[ladder]] -id = "af" -component = "a" capability = "wai.audio.flac" -role = "floor" +role = "base" params = { source = "encode" } [floor] diff --git a/wai/pack-conformance/policy/expected.json b/wai/pack-conformance/policy/expected.json index 2678a4a5b..af918f9d2 100644 --- a/wai/pack-conformance/policy/expected.json +++ b/wai/pack-conformance/policy/expected.json @@ -22,13 +22,13 @@ }, { "file": "example.toml", - "canonical": "{\"asset_id\":\"pack-fixture-01\",\"class\":[{\"floor_only\":false,\"id\":\"mobile\",\"max_kbps\":900,\"max_layer\":1},{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":2},{\"floor_only\":true,\"id\":\"legacy\",\"max_layer\":0}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"source\":\"src/video.y4m\",\"timebase\":{\"den\":1,\"num\":25}},{\"id\":\"a\",\"media\":\"audio\",\"source\":\"src/audio.wav\",\"timebase\":{\"den\":1,\"num\":48000}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false,\"supplied_listing\":\"0000000000000000000000000000000000000000000000000000000000000000\"},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":40000,\"mv_precision\":2,\"q_candidates\":[16,12,8],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":6},\"refines\":\"b0\",\"role\":\"refinement\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r2\",\"params\":{\"q\":3},\"refines\":\"r1\",\"role\":\"refinement\"},{\"capability\":\"wai.image.png\",\"component\":\"v\",\"id\":\"still\",\"params\":{},\"role\":\"base\"},{\"capability\":\"wai.video.av1\",\"component\":\"v\",\"id\":\"f0\",\"params\":{\"source\":\"supplied\"},\"role\":\"floor\"},{\"capability\":\"wai.audio.int_layered\",\"component\":\"a\",\"id\":\"ab\",\"params\":{\"rate\":8000},\"role\":\"base\"},{\"capability\":\"wai.audio.int_refine\",\"component\":\"a\",\"id\":\"ar1\",\"params\":{\"q\":64,\"to_rate\":48000},\"refines\":\"ab\",\"role\":\"refinement\"},{\"capability\":\"wai.audio.flac\",\"component\":\"a\",\"id\":\"af\",\"params\":{\"source\":\"encode\"},\"role\":\"floor\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,200,400]},\"moment\":[{\"from_ms\":30000,\"id\":\"m-goal\",\"tags\":[\"event\"],\"to_ms\":34000}],\"royalty_free_only\":true,\"rule\":[{\"id\":\"static\",\"then\":{\"ladder\":[\"still\",\"f0\"]},\"when\":{\"piece_kind\":[\"static\"]}},{\"id\":\"motion\",\"then\":{\"max_layer\":1},\"when\":{\"piece_kind\":[\"motion_high\"]}},{\"id\":\"event\",\"then\":{\"deadline_ms\":800,\"max_layer\":2},\"when\":{\"moment_tag\":[\"event\"]}}],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", - "digest": "a09e8894b5f90e4a38334ddc0a20f3b253a712f1b5b3f5265e31d6e6ea098754" + "canonical": "{\"asset_id\":\"pack-fixture-01\",\"class\":[{\"floor_only\":false,\"id\":\"mobile\",\"max_kbps\":900,\"max_layer\":1},{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":2},{\"floor_only\":true,\"id\":\"legacy\",\"max_layer\":0}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"source\":\"src/video.y4m\",\"timebase\":{\"den\":1,\"num\":25}},{\"id\":\"a\",\"media\":\"audio\",\"source\":\"src/audio.wav\",\"timebase\":{\"den\":1,\"num\":48000}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false,\"supplied_listing\":\"0000000000000000000000000000000000000000000000000000000000000000\"},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":40000,\"mv_precision\":2,\"q_candidates\":[16,12,8],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":6},\"refines\":\"b0\",\"role\":\"refinement\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r2\",\"params\":{\"q\":3},\"refines\":\"r1\",\"role\":\"refinement\"},{\"capability\":\"wai.image.png\",\"component\":\"v\",\"id\":\"still\",\"params\":{},\"role\":\"base\"},{\"capability\":\"wai.video.av1\",\"component\":\"v\",\"id\":\"f0\",\"params\":{\"source\":\"supplied\"},\"role\":\"floor\"},{\"capability\":\"wai.audio.flac\",\"component\":\"a\",\"id\":\"ab\",\"params\":{\"source\":\"encode\"},\"role\":\"base\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,200,400]},\"moment\":[{\"from_ms\":30000,\"id\":\"m-goal\",\"tags\":[\"event\"],\"to_ms\":34000}],\"royalty_free_only\":true,\"rule\":[{\"id\":\"static\",\"then\":{\"ladder\":[\"still\",\"f0\"]},\"when\":{\"piece_kind\":[\"static\"]}},{\"id\":\"motion\",\"then\":{\"max_layer\":1},\"when\":{\"piece_kind\":[\"motion_high\"]}},{\"id\":\"event\",\"then\":{\"deadline_ms\":800,\"max_layer\":2},\"when\":{\"moment_tag\":[\"event\"]}}],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", + "digest": "1997e19e06f6bf191d5de605ba1a4719a3372d5e7a168b5364cc82638d9dd0e9" }, { "file": "example.toml", - "canonical": "{\"asset_id\":\"pack-fixture-01\",\"class\":[{\"floor_only\":false,\"id\":\"mobile\",\"max_kbps\":900,\"max_layer\":1},{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":2},{\"floor_only\":true,\"id\":\"legacy\",\"max_layer\":0}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"source\":\"src/video.y4m\",\"timebase\":{\"den\":1,\"num\":25}},{\"id\":\"a\",\"media\":\"audio\",\"source\":\"src/audio.wav\",\"timebase\":{\"den\":1,\"num\":48000}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false,\"supplied_listing\":\"0000000000000000000000000000000000000000000000000000000000000000\"},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":40000,\"mv_precision\":2,\"q_candidates\":[16,12,8],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":6},\"refines\":\"b0\",\"role\":\"refinement\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r2\",\"params\":{\"q\":3},\"refines\":\"r1\",\"role\":\"refinement\"},{\"capability\":\"wai.image.png\",\"component\":\"v\",\"id\":\"still\",\"params\":{},\"role\":\"base\"},{\"capability\":\"wai.video.av1\",\"component\":\"v\",\"id\":\"f0\",\"params\":{\"source\":\"supplied\"},\"role\":\"floor\"},{\"capability\":\"wai.audio.int_layered\",\"component\":\"a\",\"id\":\"ab\",\"params\":{\"rate\":8000},\"role\":\"base\"},{\"capability\":\"wai.audio.int_refine\",\"component\":\"a\",\"id\":\"ar1\",\"params\":{\"q\":64,\"to_rate\":48000},\"refines\":\"ab\",\"role\":\"refinement\"},{\"capability\":\"wai.audio.flac\",\"component\":\"a\",\"id\":\"af\",\"params\":{\"source\":\"encode\"},\"role\":\"floor\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,200,400]},\"moment\":[{\"from_ms\":30000,\"id\":\"m-goal\",\"tags\":[\"event\"],\"to_ms\":34000}],\"royalty_free_only\":false,\"rule\":[{\"id\":\"static\",\"then\":{\"ladder\":[\"still\",\"f0\"]},\"when\":{\"piece_kind\":[\"static\"]}},{\"id\":\"motion\",\"then\":{\"max_layer\":1},\"when\":{\"piece_kind\":[\"motion_high\"]}},{\"id\":\"event\",\"then\":{\"deadline_ms\":800,\"max_layer\":2},\"when\":{\"moment_tag\":[\"event\"]}}],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", - "digest": "48177936de4431ad8e64104431b131bf1670dfb549476dda81f547185ce22e18", + "canonical": "{\"asset_id\":\"pack-fixture-01\",\"class\":[{\"floor_only\":false,\"id\":\"mobile\",\"max_kbps\":900,\"max_layer\":1},{\"floor_only\":false,\"id\":\"tv\",\"max_layer\":2},{\"floor_only\":true,\"id\":\"legacy\",\"max_layer\":0}],\"classify\":{\"motion_high_milli\":9000,\"static_mad_milli\":300,\"static_permille\":5,\"synthetic_bins\":12},\"component\":[{\"id\":\"v\",\"media\":\"video\",\"source\":\"src/video.y4m\",\"timebase\":{\"den\":1,\"num\":25}},{\"id\":\"a\",\"media\":\"audio\",\"source\":\"src/audio.wav\",\"timebase\":{\"den\":1,\"num\":48000}}],\"defaults\":{\"deadline_ms\":1500},\"floor\":{\"extra_keyframes\":false,\"supplied_listing\":\"0000000000000000000000000000000000000000000000000000000000000000\"},\"ladder\":[{\"capability\":\"wai.video.int_motion\",\"component\":\"v\",\"id\":\"b0\",\"params\":{\"block\":16,\"max_sse_px_milli\":40000,\"mv_precision\":2,\"q_candidates\":[16,12,8],\"search\":7},\"role\":\"base\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r1\",\"params\":{\"q\":6},\"refines\":\"b0\",\"role\":\"refinement\"},{\"capability\":\"wai.video.int_refine\",\"component\":\"v\",\"id\":\"r2\",\"params\":{\"q\":3},\"refines\":\"r1\",\"role\":\"refinement\"},{\"capability\":\"wai.image.png\",\"component\":\"v\",\"id\":\"still\",\"params\":{},\"role\":\"base\"},{\"capability\":\"wai.video.av1\",\"component\":\"v\",\"id\":\"f0\",\"params\":{\"source\":\"supplied\"},\"role\":\"floor\"},{\"capability\":\"wai.audio.flac\",\"component\":\"a\",\"id\":\"ab\",\"params\":{\"source\":\"encode\"},\"role\":\"base\"}],\"lead\":\"v\",\"live\":{\"layer_lag_ms\":[0,200,400]},\"moment\":[{\"from_ms\":30000,\"id\":\"m-goal\",\"tags\":[\"event\"],\"to_ms\":34000}],\"royalty_free_only\":false,\"rule\":[{\"id\":\"static\",\"then\":{\"ladder\":[\"still\",\"f0\"]},\"when\":{\"piece_kind\":[\"static\"]}},{\"id\":\"motion\",\"then\":{\"max_layer\":1},\"when\":{\"piece_kind\":[\"motion_high\"]}},{\"id\":\"event\",\"then\":{\"deadline_ms\":800,\"max_layer\":2},\"when\":{\"moment_tag\":[\"event\"]}}],\"schema\":\"wai.pack.policy/1\",\"segmentation\":{\"closed\":true,\"cut_abs_permille\":300,\"cut_rel_permille\":3000,\"cut_window\":8,\"lookahead_frames\":6,\"max_ms\":4000,\"min_ms\":1000,\"scene_cut\":\"wai.pack.scenecut/1\",\"target_ms\":2000}}", + "digest": "53d2fb3b7ad5cfb211e86baaf4faec205b1332b0fe6875d9525c04ff021187bd", "overrides": { "royalty_free_only": false } diff --git a/wai/wai-pack/src/conformance.rs b/wai/wai-pack/src/conformance.rs index 3b4b4140c..0c0dcdd04 100644 --- a/wai/wai-pack/src/conformance.rs +++ b/wai/wai-pack/src/conformance.rs @@ -3,9 +3,9 @@ //! independent verifier (`pack-conformance/pack_verify.py`) recomputes the same //! values from the extension text. //! -//! - `policy/`: policies and `expected.json` (each one's canonical JSON and digest, -//! with any overrides; and the pairs `eq--a.toml`, `eq--b.toml`, two -//! spellings of one policy, which must have one digest). +//! - `policy/`: policies a packager accepts, and `expected.json` (each one's canonical +//! JSON and digest, with any overrides; and the pairs `eq--a.toml`, +//! `eq--b.toml`, two spellings of one policy, which must have one digest). //! - `refusals/`: policies and sources a packager refuses, and `expected.json` (each //! one's reason). The binary sources are written by [`regen`] from //! [`source_refusals`]. @@ -395,6 +395,14 @@ pub fn check(dir: &Path) -> std::io::Result> { let digest_of = |f: &str| { pol["cases"].as_array().and_then(|c| c.iter().find(|x| x["file"] == f && x.get("overrides").is_none())).map(|x| x["digest"].clone()) }; + // Every policy of the corpus is one a packager accepts, its rungs included (§3.6): + // the example is a policy someone can pack under. + for name in tomls(&dir.join("policy"))? { + let v = policy_verdict(&read(&dir.join("policy").join(&name))?); + if v != "ok" { + out.push(format!("policy/{name}: a packager refuses it ({v})")); + } + } for pair in pol["equal"].as_array().into_iter().flatten() { let (a, b) = (pair[0].as_str().unwrap_or(""), pair[1].as_str().unwrap_or("")); if digest_of(a).is_none() || digest_of(a) != digest_of(b) { diff --git a/wai/wai-pack/src/policy.rs b/wai/wai-pack/src/policy.rs index 4320cf17a..b3eb5b22a 100644 --- a/wai/wai-pack/src/policy.rs +++ b/wai/wai-pack/src/policy.rs @@ -616,7 +616,6 @@ fn param_schema(cap: &str) -> &'static [(&'static str, Param)] { ], "wai.video.int_refine" => &[("q", Int { lo: 1, hi: 255, default: None })], "wai.video.av1" | "wai.video.av1.lossless" => &[("source", Word { allowed: &["supplied", "encode"], default: "supplied" })], - "wai.audio.int_layered" => &[("rate", Int { lo: 1, hi: 384_000, default: None })], "wai.audio.int_refine" => &[ ("to_rate", Int { lo: 1, hi: 384_000, default: None }), ("q", Int { lo: 1, hi: 65_535, default: None }), -- 2.54.0 (Apple Git-157) From 9bf97ccd452f8e91c1b62319fe36b3d8e53cbde8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:14:20 -0400 Subject: [PATCH 15/24] wai-pack: a cut window holds at least 3 scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round, item 7. The relative test of wai.pack.scenecut/1 applies only to a window of 3 or more scores, so a cut_window of 1 or 2 left cut_rel_permille inert while the policy stated it. The range is now 3 to 64 (policy-value), in both implementations and §3.3, with a corpus case. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/packaging.md | 2 +- wai/pack-conformance/pack_verify.py | 2 +- wai/pack-conformance/refusals/expected.json | 4 ++++ .../refusals/policy/value-cut-window.toml | 18 ++++++++++++++++++ wai/wai-pack/src/policy.rs | 4 +++- 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 wai/pack-conformance/refusals/policy/value-cut-window.toml diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index d9b79e87b..ac0d479ee 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -81,7 +81,7 @@ absent table reads as empty. | `segmentation.scene_cut` | string | `wai.pack.scenecut/1` | `wai.pack.scenecut/1` | | `segmentation.cut_abs_permille` | integer | 300 | 1 to 1000 | | `segmentation.cut_rel_permille` | integer | 3000 | 0 to 1000000 | -| `segmentation.cut_window` | integer | 8 | 1 to 64 | +| `segmentation.cut_window` | integer | 8 | 3 to 64 (a window of fewer than 3 scores never applies `cut_rel_permille`, §4.3) | | `segmentation.lookahead_frames` | integer | 6 | 0 to 64 | | `segmentation.closed` | boolean | `true` | | | `ladder` | array of tables | empty | | diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index e7bab520b..2b0431fda 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -294,7 +294,7 @@ def read_policy(doc): seg["scene_cut"] = sc seg["cut_abs_permille"] = s.uint_in("cut_abs_permille", 1, 1000, 300) seg["cut_rel_permille"] = s.uint_in("cut_rel_permille", 0, 1000000, 3000) - seg["cut_window"] = s.uint_in("cut_window", 1, 64, 8) + seg["cut_window"] = s.uint_in("cut_window", 3, 64, 8) seg["lookahead_frames"] = s.uint_in("lookahead_frames", 0, 64, 6) seg["closed"] = s.boolean("closed", True) P["segmentation"] = seg diff --git a/wai/pack-conformance/refusals/expected.json b/wai/pack-conformance/refusals/expected.json index 64781875e..5cd23461f 100644 --- a/wai/pack-conformance/refusals/expected.json +++ b/wai/pack-conformance/refusals/expected.json @@ -164,6 +164,10 @@ "file": "policy/value-block.toml", "reason": "policy-value" }, + { + "file": "policy/value-cut-window.toml", + "reason": "policy-value" + }, { "file": "policy/value-media.toml", "reason": "policy-value" diff --git a/wai/pack-conformance/refusals/policy/value-cut-window.toml b/wai/pack-conformance/refusals/policy/value-cut-window.toml new file mode 100644 index 000000000..6bc23c40d --- /dev/null +++ b/wai/pack-conformance/refusals/policy/value-cut-window.toml @@ -0,0 +1,18 @@ +# Refused: a cut window of 2, under which the relative test never applies. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[segmentation] +cut_window = 2 + +[[ladder]] +id = "b0" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/wai-pack/src/policy.rs b/wai/wai-pack/src/policy.rs index b3eb5b22a..66a6e1a83 100644 --- a/wai/wai-pack/src/policy.rs +++ b/wai/wai-pack/src/policy.rs @@ -714,7 +714,9 @@ fn read(doc: &toml::Table) -> R<(Policy, Option>)> { }, cut_abs_permille: s.uint_in("cut_abs_permille", 1, 1000, Some(300))?, cut_rel_permille: s.uint_in("cut_rel_permille", 0, 1_000_000, Some(3000))?, - cut_window: s.uint_in("cut_window", 1, 64, Some(8))?, + // At least 3: the relative test (§4.3 step 4) applies only to a window of 3 or + // more scores, so a window of 1 or 2 would leave cut_rel_permille inert. + cut_window: s.uint_in("cut_window", 3, 64, Some(8))?, lookahead_frames: s.uint_in("lookahead_frames", 0, 64, Some(6))?, closed: s.boolean("closed", true)?, }; -- 2.54.0 (Apple Git-157) From 932497db7706685df31e109db4356e06d541f287 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:17:16 -0400 Subject: [PATCH 16/24] wai-pack: ids are one namespace across components, rungs, classes, moments and rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round, item 10. A component and a rung could share an id, so a reference (a lead, a rung's component, refines, a rule's ladder) could name two things. Every id is now unique across the five kinds together (policy-duplicate-id), in both implementations and §3.4(2), with a corpus case of a component and a rung both named v. The corpus README notes that two golden payloads are byte-identical because their frames are. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/packaging.md | 4 ++-- wai/pack-conformance/README.md | 4 +++- wai/pack-conformance/pack_verify.py | 6 +++--- wai/pack-conformance/refusals/expected.json | 4 ++++ .../policy/duplicate-id-across-kinds.toml | 15 ++++++++++++++ wai/wai-pack/src/policy.rs | 20 +++++++++---------- 6 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 wai/pack-conformance/refusals/policy/duplicate-id-across-kinds.toml diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index ac0d479ee..95e11efa6 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -139,8 +139,8 @@ first that fails is the refusal: 1. 1 to 8 components (`policy-too-many-components`); at most 64 rungs (`policy-too-many-rungs`), 32 classes (`policy-too-many-classes`), 32 rules (`policy-too-many-rules`) and 32 moments (`policy-too-many-moments`). -2. Ids unique among components, rungs, classes, moments and rules - (`policy-duplicate-id`). +2. Ids unique among components, rungs, classes, moments and rules taken together, so + a reference never names two things (`policy-duplicate-id`). 3. `lead` names a component (`policy-lead`). 4. `segmentation.closed` is true: every piece is closed, §4.1 (`policy-open-gop`). 5. `1 ≤ min_ms ≤ target_ms ≤ max_ms` (`policy-piece-bounds`). diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index e416d58e8..f7d2b6cc9 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -28,7 +28,9 @@ digest and what it may ask a packager to emit (§3), how a source is cut into pi of every fixture, coding the piece's first 12 frames, under a whole-pel and a half-pel parameter set; for each, the encoder's parameters, the payload's BLAKE3, the SSE of its reconstruction against the source, and the SHA-256 of every decoded - frame. + frame. Identical content gives identical bytes: `kinds-01`'s pieces 4 and 5 both + start inside one static shot, so their first frames, and their payloads, are the + same. The fixtures are not committed. `wai-pack fixture gen ` writes each from its definition (`wai-pack/src/fixture.rs`), every byte an integer function of the frame diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index 2b0431fda..db767596f 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -421,9 +421,9 @@ def constrain(P, lag): no("policy-too-many-rules") if len(P["moment"]) > 32: no("policy-too-many-moments") - for k in ("component", "ladder", "class", "moment", "rule"): - if first_dup(x["id"] for x in P[k]) is not None: - no("policy-duplicate-id") + # One namespace for every id, so no reference names two things. + if first_dup(x["id"] for k in ("component", "ladder", "class", "moment", "rule") for x in P[k]) is not None: + no("policy-duplicate-id") if P["lead"] not in [c["id"] for c in P["component"]]: no("policy-lead") s = P["segmentation"] diff --git a/wai/pack-conformance/refusals/expected.json b/wai/pack-conformance/refusals/expected.json index 5cd23461f..dc59a8eff 100644 --- a/wai/pack-conformance/refusals/expected.json +++ b/wai/pack-conformance/refusals/expected.json @@ -32,6 +32,10 @@ "file": "policy/deadline-unreachable.toml", "reason": "policy-deadline-unreachable" }, + { + "file": "policy/duplicate-id-across-kinds.toml", + "reason": "policy-duplicate-id" + }, { "file": "policy/duplicate-id.toml", "reason": "policy-duplicate-id" diff --git a/wai/pack-conformance/refusals/policy/duplicate-id-across-kinds.toml b/wai/pack-conformance/refusals/policy/duplicate-id-across-kinds.toml new file mode 100644 index 000000000..e34fce225 --- /dev/null +++ b/wai/pack-conformance/refusals/policy/duplicate-id-across-kinds.toml @@ -0,0 +1,15 @@ +# Refused: a component and a rung both named v. +schema = "wai.pack.policy/1" +asset_id = "r" +lead = "v" + +[[component]] +id = "v" +media = "video" +timebase = { num = 25, den = 1 } + +[[ladder]] +id = "v" +component = "v" +capability = "wai.video.int_motion" +role = "base" diff --git a/wai/wai-pack/src/policy.rs b/wai/wai-pack/src/policy.rs index 66a6e1a83..b652a2a15 100644 --- a/wai/wai-pack/src/policy.rs +++ b/wai/wai-pack/src/policy.rs @@ -93,7 +93,7 @@ pub enum PolicyReason { TooManyRules, /// More than 32 moments. TooManyMoments, - /// Two components, rungs, classes, moments or rules share an id. + /// Two ids are equal, among components, rungs, classes, moments and rules together. DuplicateId, /// `lead` names no component. Lead, @@ -916,15 +916,15 @@ impl Policy { if self.moments.len() > MAX_MOMENTS { return e(P::TooManyMoments, "moment"); } - let dups = [ - ("component", first_duplicate(self.components.iter().map(|c| c.id.as_str()))), - ("ladder", first_duplicate(self.ladder.iter().map(|r| r.id.as_str()))), - ("class", first_duplicate(self.classes.iter().map(|c| c.id.as_str()))), - ("moment", first_duplicate(self.moments.iter().map(|m| m.id.as_str()))), - ("rule", first_duplicate(self.rules.iter().map(|r| r.id.as_str()))), - ]; - if let Some((at, Some(id))) = dups.into_iter().find(|(_, d)| d.is_some()) { - return e(P::DuplicateId, &format!("{at}.{id}")); + // One namespace for every id, so a reference (a lead, a rung's component, a + // refines, a rule's ladder) never names two things. + let ids = self.components.iter().map(|c| c.id.as_str()) + .chain(self.ladder.iter().map(|r| r.id.as_str())) + .chain(self.classes.iter().map(|c| c.id.as_str())) + .chain(self.moments.iter().map(|m| m.id.as_str())) + .chain(self.rules.iter().map(|r| r.id.as_str())); + if let Some(id) = first_duplicate(ids) { + return e(P::DuplicateId, id); } if !self.components.iter().any(|c| c.id == self.lead) { return e(P::Lead, "lead"); -- 2.54.0 (Apple Git-157) From 82637436dcf64eac6cb4e711c7c53f6dd43e0c24 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:20:07 -0400 Subject: [PATCH 17/24] wai-pack: the segmenter's state is bounded by its lookahead and window Fix round, item 9. The streaming segmenter kept every frame's score, so its memory grew with the stream. Scores are now an opt-in batch output (Segmenter::with_scores, which segment() uses); a streaming segmenter holds at most 2L + 2 frame records and the window, and Segmenter::retained says how much. A test pushes 60,000 frames and checks the bound at every one. segment_diff.py (from the review's segdiff) compares the streaming segmenter, through the wai-pack segment-json aid, with pack_verify.py's on-demand form on seeded random clips, and checks the min and max bounds of every piece; CI runs it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 8 ++- wai/pack-conformance/README.md | 7 +++ wai/pack-conformance/segment_diff.py | 90 ++++++++++++++++++++++++++++ wai/wai-pack/src/bin/wai_pack.rs | 50 ++++++++++++++++ wai/wai-pack/src/scenecut.rs | 49 ++++++++++++--- 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 wai/pack-conformance/segment_diff.py diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 9d94dbec9..2eb1c9f33 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -86,7 +86,7 @@ jobs: echo "$target: $n tests (floor $floor)" [ "$n" -ge "$floor" ] || { echo "::error::$target lists $n tests, under its floor of $floor"; fail=1; } done <<'EOF' - --lib 15 + --lib 16 --test=conformance 1 --test=scenecut 3 --test=source_alloc 1 @@ -139,6 +139,12 @@ jobs: shell: bash working-directory: wai/wai-pack run: python3 ../pack-conformance/policy_diff.py target/release/wai-pack 1 3000 + # wai.pack.scenecut/1 differentially: the streaming segmenter against + # pack_verify.py's on-demand form on seeded random clips. + - name: segment_diff.py, wai-pack against pack_verify.py + shell: bash + working-directory: wai/wai-pack + run: python3 ../pack-conformance/segment_diff.py target/release/wai-pack 1 2000 licences: name: wai-pack · dependency licences diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index f7d2b6cc9..838d9b744 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -77,6 +77,13 @@ Files are compared as bytes; `.gitattributes` turns off line-ending conversion. python3 pack-conformance/policy_diff.py 1 3000 +- `segment_diff.py` runs `wai.pack.scenecut/1` differentially: seeded random clips + under random parameters, every piece and score of the streaming segmenter + (`wai-pack segment-json`) equal to `pack_verify.py`'s on-demand form, and every piece + within `min` and `max`. + + python3 pack-conformance/segment_diff.py 1 2000 + - `source_diff.py` runs the source readers differentially: seeded Y4M and WAV mutations (dimensions and channel counts at and past their range, every format field, truncations, single-byte changes), each file's verdict from diff --git a/wai/pack-conformance/segment_diff.py b/wai/pack-conformance/segment_diff.py new file mode 100644 index 000000000..51b473d71 --- /dev/null +++ b/wai/pack-conformance/segment_diff.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""wai.pack.scenecut/1, differentially: wai-pack's streaming segmenter against +pack_verify.py's on-demand form. + +Seeded random clips (flat and noisy shots, cuts, flashes, small perturbations, empty and +one-frame clips) under random parameters; every piece (start, length, flags and the +statistics classification reads) and every score from `wai-pack segment-json`, pushed +a frame at a time or in a batch, must equal pack_verify.py's, and every piece but the +last must lie between `min` and `max` frames (the last at most `max`). + + python3 segment_diff.py [seed] [count] + +Exit status 0 only if every clip agrees. +""" +import json +import os +import random +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import pack_verify as PV # noqa: E402 + + +def clip(rng): + w, h = rng.randint(1, 5), rng.randint(1, 4) + scenes = [] + for _ in range(rng.randint(1, 5)): + if rng.random() < 0.5: + scenes.append([rng.randrange(256)] * (w * h * 3)) + else: + scenes.append([rng.randrange(256) for _ in range(w * h * 3)]) + n = rng.randint(0, 40) if rng.random() < 0.9 else rng.randint(0, 3) + frames, cur = [], 0 + for _ in range(n): + if rng.random() < 0.15: + cur = rng.randrange(len(scenes)) + f = scenes[cur] + if rng.random() < 0.1: + f = scenes[rng.randrange(len(scenes))] + if rng.random() < 0.2: + f = [min(255, max(0, x + rng.randint(-6, 6))) for x in f] + frames.append(list(f)) + mn = rng.randint(1, 6) + tg = mn + rng.randint(0, 6) + mx = tg + rng.randint(0, 8) + p = {"min": mn, "target": tg, "max": mx, "cut_abs": rng.choice([1, 50, 100, 300, 500, 1000]), + "cut_rel": rng.choice([0, 1000, 3000, 1000000]), "window": rng.randint(1, 8), "lookahead": rng.randint(0, 7)} + return {"w": w, "h": h, "p": p, "frames": frames, "stream": rng.random() < 0.5} + + +def main(argv): + if len(argv) < 2: + print(__doc__) + return 2 + binary = argv[1] + seed = int(argv[2]) if len(argv) > 2 else 1 + count = int(argv[3]) if len(argv) > 3 else 2000 + rng = random.Random(seed) + cases = [clip(rng) for _ in range(count)] + r = subprocess.run([binary, "segment-json"], input=("\n".join(json.dumps(c) for c in cases) + "\n").encode(), + capture_output=True) + outs = r.stdout.decode().splitlines() + if r.returncode != 0 or len(outs) != count: + print(r.stderr.decode(errors="replace")[-2000:]) + return 1 + bad = 0 + for c, o in zip(cases, outs): + ro = json.loads(o) + if not c["frames"]: + ok = ro["pieces"] == [] + else: + pp, ps = PV.segment(c["frames"], c["w"], c["h"], c["p"]) + py = [[x["start"], x["len"], x["flags"], x["abs_diff"], x["changed"], x["compared"], x["occupancy"]] for x in pp] + ok = py == ro["pieces"] and ps == ro["scores"] + pcs = ro["pieces"] + for i, x in enumerate(pcs): + if x[1] > c["p"]["max"] or (i + 1 < len(pcs) and x[1] < c["p"]["min"]): + ok = False + if not ok: + bad += 1 + if bad <= 5: + print("DIFF", json.dumps(c["p"]), "frames", len(c["frames"]), "stream", c["stream"]) + print(f"segment_diff: seed {seed}, {count} clips, {bad} differences") + return 0 if bad == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/wai/wai-pack/src/bin/wai_pack.rs b/wai/wai-pack/src/bin/wai_pack.rs index 2a4024c82..d434c9a57 100644 --- a/wai/wai-pack/src/bin/wai_pack.rs +++ b/wai/wai-pack/src/bin/wai_pack.rs @@ -13,6 +13,11 @@ //! wai-pack source-verdict ... //! each source's verdict (`ok`, or the reason it is refused), one `\t` //! line each; a reader that panics prints `PANIC` +//! wai-pack segment-json +//! a conformance aid: reads JSON lines {"w","h","p":{min,target,max,cut_abs,cut_rel, +//! window,lookahead},"frames":[[RGB8 bytes]...],"stream":bool} and writes, per line, +//! {"pieces":[[start,len,flags,abs_diff,changed,compared,occupancy]...],"scores":[...]}, +//! pushing frames one at a time when "stream" is true //! wai-pack fixture gen [-o ] //! write a conformance fixture (Y4M); `fixture list` names them //! wai-pack conformance regen|check @@ -115,6 +120,50 @@ fn cmd_source_verdict(args: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_segment_json() -> Result<(), String> { + use std::io::BufRead as _; + for line in std::io::stdin().lock().lines() { + let line = line.map_err(|e| e.to_string())?; + let v: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + let n = |x: &serde_json::Value| x.as_u64().ok_or("segment-json: a field is not an integer"); + let (w, h) = (n(&v["w"])? as u32, n(&v["h"])? as u32); + let q = &v["p"]; + let p = scenecut::SegParams { + min: n(&q["min"])?, + target: n(&q["target"])?, + max: n(&q["max"])?, + cut_abs: n(&q["cut_abs"])?, + cut_rel: n(&q["cut_rel"])?, + window: n(&q["window"])? as usize, + lookahead: n(&q["lookahead"])?, + }; + let frames: Vec> = v["frames"] + .as_array() + .ok_or("segment-json: frames")? + .iter() + .map(|f| f.as_array().map(|a| a.iter().map(|x| x.as_u64().unwrap_or(0) as u8).collect()).unwrap_or_default()) + .collect(); + let (pieces, scores) = if v["stream"].as_bool().unwrap_or(false) { + let mut s = scenecut::Segmenter::with_scores(p, w, h); + let mut out = Vec::new(); + for f in &frames { + out.extend(s.push(f)); + } + let sc = s.scores().to_vec(); + out.extend(s.finish()); + (out, sc) + } else { + scenecut::segment(p, w, h, &frames) + }; + let ps: Vec = pieces + .iter() + .map(|x| serde_json::json!([x.start, x.len, x.flags, x.stats.abs_diff, x.stats.changed, x.stats.compared, x.stats.occupancy])) + .collect(); + println!("{}", serde_json::json!({"pieces": ps, "scores": scores})); + } + Ok(()) +} + fn cmd_fixture(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("list") => { @@ -167,6 +216,7 @@ fn main() -> ExitCode { "plan" => cmd_plan(rest), "policy-verdict" => cmd_policy_verdict(rest), "source-verdict" => cmd_source_verdict(rest), + "segment-json" => cmd_segment_json(), "fixture" => cmd_fixture(rest), "conformance" => cmd_conformance(rest), _ => return usage(), diff --git a/wai/wai-pack/src/scenecut.rs b/wai/wai-pack/src/scenecut.rs index cdbb918f3..fa74210e1 100644 --- a/wai/wai-pack/src/scenecut.rs +++ b/wai/wai-pack/src/scenecut.rs @@ -26,7 +26,9 @@ //! `L` is `lookahead_frames` and `last` the index of the source's last frame. A //! decision at `f` reads no frame after `f + L`, so [`Segmenter::push`] decides frame //! `f` as soon as frame `f + L` arrives, and [`Segmenter::finish`] decides the rest -//! once `last` is known. Every piece has `min ≤ len ≤ max`, except the source's last, +//! once `last` is known. What the segmenter holds is bounded by `L` and the window, not +//! by the stream's length ([`Segmenter::retained`]): every frame's score is kept only by +//! [`Segmenter::with_scores`], for a batch caller. Every piece has `min ≤ len ≤ max`, except the source's last, //! which may be shorter than `min`. There is one implementation, used by both //! on-demand and live packing. //! @@ -156,8 +158,8 @@ pub struct Segmenter { stats: PieceStats, window: VecDeque, prev_y: Vec, - /// Every frame's score, in order (frame 0's is 0). - scores: Vec, + /// Every frame's score, in order (frame 0's is 0), when the caller asked for them. + scores: Option>, } impl Segmenter { @@ -174,13 +176,26 @@ impl Segmenter { stats: PieceStats::default(), window: VecDeque::new(), prev_y: Vec::new(), - scores: Vec::new(), + scores: None, } } - /// Every frame's score so far, frame 0's as 0. + /// A segmenter that also keeps every frame's score: a batch output, which grows with + /// the stream. + pub fn with_scores(p: SegParams, w: u32, h: u32) -> Self { + Segmenter { scores: Some(Vec::new()), ..Segmenter::new(p, w, h) } + } + + /// Every frame's score so far, frame 0's as 0; empty unless made + /// [`Segmenter::with_scores`]. pub fn scores(&self) -> &[u64] { - &self.scores + self.scores.as_deref().unwrap_or(&[]) + } + + /// The per-frame records and window scores the segmenter holds (and the scores, when + /// it keeps them): without scores, at most `2·L + 2` records and the window. + pub fn retained(&self) -> usize { + self.recs.len() + self.window.len() + self.scores.as_ref().map_or(0, Vec::len) } fn rec(&self, f: u64) -> &Rec { @@ -202,7 +217,9 @@ impl Segmenter { } let occupancy = hist.iter().filter(|&&c| c != 0).count() as u32; self.recs.push_back(Rec { hist, score, abs_diff, changed, occupancy }); - self.scores.push(score); + if let Some(s) = self.scores.as_mut() { + s.push(score); + } self.prev_y = y; self.n += 1; let mut out = Vec::new(); @@ -295,7 +312,7 @@ fn lower_median(w: &VecDeque) -> u64 { /// Segment frames held in memory: the pieces and every frame's score. pub fn segment(p: SegParams, w: u32, h: u32, frames: impl IntoIterator>) -> (Vec, Vec) { - let mut s = Segmenter::new(p, w, h); + let mut s = Segmenter::with_scores(p, w, h); let mut pieces = Vec::new(); for f in frames { pieces.extend(s.push(f.as_ref())); @@ -386,4 +403,20 @@ mod tests { assert_eq!(closed_at, vec![9]); assert_eq!(plan_digest(&[0, 6]), plan_digest(&starts(params(3), &f))); } + + /// Over a long stream, what the segmenter holds stays within its lookahead and window: + /// `2·L + 2` records and the window, whatever the stream's length. + #[test] + fn state_is_bounded_over_a_long_stream() { + let p = params(6); + let mut s = Segmenter::new(p, 2, 2); + let mut pieces = 0; + for t in 0..60_000u32 { + let v = ((t / 7) * 37 % 251) as u8; + pieces += s.push(&[v; 12]).len(); + assert!(s.retained() <= 2 * p.lookahead as usize + 2 + p.window, "frame {t}: {}", s.retained()); + } + assert!(pieces > 1000); + assert!(s.scores().is_empty()); + } } -- 2.54.0 (Apple Git-157) From 9fa6ca580f222e83ed9956d8ee9562114f9b7aff Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:21:25 -0400 Subject: [PATCH 18/24] wai-pack: the cbindgen licence exception names its version, and CI asserts it is build-only Fix round, item 8. deny.toml's MPL-2.0 exception named cbindgen at any version and in any role: a crate that linked cbindgen as a normal dependency passed the check (the review's probe). The exception now names cbindgen@0.29.4, and the licences job asserts that cargo tree -e normal finds no path from wai-pack to cbindgen, while the build edge is found. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 20 ++++++++++++++++++++ wai/wai-pack/deny.toml | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 2eb1c9f33..b967cdf37 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -166,3 +166,23 @@ jobs: - name: cargo deny check licenses working-directory: wai/wai-pack run: cargo deny --locked check licenses + # deny.toml's MPL-2.0 exception covers cbindgen as wai's build-time tool: it must + # reach wai-pack through no normal (linked) edge. The second query sees the build + # edge, so an empty answer from the first is an answer. + - name: cbindgen is a build dependency only + shell: bash + working-directory: wai/wai-pack + run: | + set -euo pipefail + err=$(mktemp) + if ! linked=$(cargo tree --locked -e normal --target all -i cbindgen 2>"$err"); then + grep -q "did not match any packages" "$err" || { cat "$err"; exit 1; } + linked="" + fi + if [ -n "$linked" ]; then + echo "::error::cbindgen is a linked dependency of wai-pack; its MPL-2.0 exception covers a build tool only" + echo "$linked" + exit 1 + fi + cargo tree --locked -e normal,build --target all -i cbindgen | grep -q '^cbindgen v' || { echo "::error::cbindgen not found as wai's build dependency"; exit 1; } + echo "cbindgen is a build dependency only" diff --git a/wai/wai-pack/deny.toml b/wai/wai-pack/deny.toml index 932bb7c77..e8d0b8c76 100644 --- a/wai/wai-pack/deny.toml +++ b/wai/wai-pack/deny.toml @@ -27,7 +27,10 @@ confidence-threshold = 0.9 unused-allowed-license = "allow" # wai's build script generates its C header with cbindgen, a build-time tool: it is -# not linked into wai-pack, and its output is not covered by its licence. +# not linked into wai-pack, and its output is not covered by its licence. The exception +# names the version reviewed; another version fails the check until it is reviewed. +# CI also asserts that cbindgen reaches wai-pack through no normal (linked) edge +# (.github/workflows/wai-pack.yml, job `licences`). [[licenses.exceptions]] -name = "cbindgen" +crate = "cbindgen@0.29.4" allow = ["MPL-2.0"] -- 2.54.0 (Apple Git-157) From 73631f39ef59f27ef3249dc2a6ef67e9a033f6b0 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:34:25 -0400 Subject: [PATCH 19/24] wai: the integer video encoder codes in two passes and states its working set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round, item 5. The encoder held a residual per sample (i64) and the rANS encoder buffered a 12-byte symbol per sample: about 22 times the clip (3.03 GB on a 134 MB clip). Now: - rans.rs: the encoder's state step and the stream's finish are shared functions; RansEncoder keeps its API and its bytes; RansReverseEncoder takes values last-decoded first and holds only the words it emits. A test feeds both the same values, escapes included, and requires equal bytes (it also runs in the cross-architecture byte-exact crate, which includes rans.rs). - int_video_enc: pass 1 finds the vectors and builds the reconstruction, its SSE and the residuals' counts (every residual is in -255..=255); pass 2 derives each residual again and codes it last first into the reverse writer. The finished stream is built at its exact length. - int_video_enc::working_bytes states what encode holds beyond its input, beside the decoder's DeclaredCost (packaging §5.5 states it too), and tests/int_video_enc_memory.rs, a live-bytes allocator, checks it over noise at step 1, a smooth pan, block 1 and one frame, in both CI profiles of wai-conformance. The bytes are unchanged: the staged corpus, whose bases the zero-motion writer codes, regenerates byte for byte, and so do the 26 golden payloads. The review's 134 MB clip now peaks at about 340 MB resident, its input included. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 8 +- wai/extensions/packaging.md | 6 +- wai/wai-rs/Cargo.toml | 5 + wai/wai-rs/src/int_payload/mod.rs | 3 +- wai/wai-rs/src/int_video_enc.rs | 109 ++++++---- wai/wai-rs/src/rans.rs | 247 +++++++++++++++++------ wai/wai-rs/tests/int_video_enc_memory.rs | 84 ++++++++ 7 files changed, 359 insertions(+), 103 deletions(-) create mode 100644 wai/wai-rs/tests/int_video_enc_memory.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 823da2153..cb207fc74 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -175,7 +175,9 @@ jobs: # allocator) checks that a header past the caps is refused before anything sized from it # is allocated; tests/int_payload_cost.rs (its own binary, a live-bytes allocator) checks # that int_payload::declared_cost bounds what each decode holds, and, in the release - # step only, decodes a 2^30-byte clip a 3 KB payload codes. tests/staged_resources.rs (its + # step only, decodes a 2^30-byte clip a 3 KB payload codes; tests/int_video_enc_memory.rs + # (its own binary, the same allocator) checks that int_video_enc::working_bytes bounds + # what the encoder holds. tests/staged_resources.rs (its # own binary, a counting allocator) checks that the staged sink's work and memory per # layer grow linearly in the layer's units, that a layer past the chain budget is # refused before any stream is decoded, that what no unit holds is dropped, and that a @@ -189,10 +191,10 @@ jobs: # wai.generate.spectral vector, which `generate` enables. - name: Fast integer kernels ≡ reference kernels (overflow checks on) working-directory: wai/wai-rs - run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,staged --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test int_payload --test int_payload_preflight --test int_payload_cost --test cli_stage --test staged_resources + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,staged --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources - name: Fast integer kernels ≡ reference kernels (release, overflow checks off) working-directory: wai/wai-rs - run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,staged --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test int_payload --test int_payload_preflight --test int_payload_cost --test cli_stage --test staged_resources + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate,staged --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate --test int_payload --test int_payload_preflight --test int_payload_cost --test int_video_enc_memory --test cli_stage --test staged_resources # The integer decode path builds under each of its features alone, so a sink can take # one medium without the others, and for wasm32 (a browser sink); `int_payload` arms a # medium only under its feature, and none needs `neural`. diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index 95e11efa6..c91c8c012 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -311,7 +311,11 @@ of the source frames and the rung's parameters (§3.3: `block`, `search`, requires. The encoder also states the SSE of its reconstruction against the source: the sum of -squared differences over every sample. +squared differences over every sample. It codes in two passes (the first finds the +vectors, the reconstruction and the residuals' counts; the second derives each +residual again and codes it last first), so beyond its input it holds the +reconstruction, the vectors and the stream: at most `n·h·w·3 + 24n + 12·v + 3·(2·n·h·w·3 ++ 16) + 8192` bytes for `n` frames and `v` motion vectors (`int_video_enc::working_bytes`). ## 6–11. Stage index, floor, classes and delivery, receipt diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 28e180bdd..648c74a2a 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -711,6 +711,11 @@ name = "int_payload_cost" path = "tests/int_payload_cost.rs" required-features = ["video_int", "audio_int", "splat_int"] +[[test]] +name = "int_video_enc_memory" +path = "tests/int_video_enc_memory.rs" +required-features = ["video_int"] + [[test]] name = "generate" path = "tests/generate.rs" diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 903f531fe..62cd667a4 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -393,7 +393,8 @@ pub fn decode_int(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result i64 { if r < 0 { -mag } else { mag } } -/// The clip's residual table and offset: the histogram of the residuals from the least -/// (`offset`), at most 4,095 symbols wide, fitted by `int_entropy::hist_to_cdf` at -/// precision 16. A residual past its span is coded through the bypass escape. -pub fn residual_table(rs: &[i64]) -> Result<(Vec, i32), IntPayloadError> { +/// The largest residual magnitude: a source and a prediction are both in 0..=255 and +/// `q ≥ 1`, so every residual is in `−255..=255`. +/// +/// cbindgen:ignore +pub const RESIDUAL_MAX: i64 = 255; + +/// The count of each residual `−255..=255`, at index `r + 255`. +pub type ResidualCounts = [u64; 2 * RESIDUAL_MAX as usize + 1]; + +/// The clip's residual table and offset from its residual counts: the histogram of the +/// residuals from the least (`offset`) to the greatest, at most 4,095 symbols wide, +/// fitted by `int_entropy::hist_to_cdf` at precision 16. A residual past its span would +/// be coded through the bypass escape; the residuals' span, at most 511, never is. +pub fn residual_table(counts: &ResidualCounts) -> Result<(Vec, i32), IntPayloadError> { let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); - let lo = *rs.iter().min().ok_or_else(|| field("no residual to fit a table to"))?; - let hi = *rs.iter().max().ok_or_else(|| field("no residual to fit a table to"))?; - let width = ((hi - lo + 1) as u64).min(4095); - let mut hist = vec![0u64; width as usize]; - for &r in rs { - if r >= lo && r < lo + width as i64 { - hist[(r - lo) as usize] += 1; - } - } - let cdf = crate::int_entropy::hist_to_cdf(&hist, 16).ok_or_else(|| field("no residual table fits"))?; - let offset = i32::try_from(lo).map_err(|_| field("a residual outside i32"))?; - Ok((cdf, offset)) + let lo = counts.iter().position(|&c| c != 0).ok_or_else(|| field("no residual to fit a table to"))?; + let hi = counts.iter().rposition(|&c| c != 0).unwrap_or(lo); + let width = (hi - lo + 1).min(4095); + let cdf = crate::int_entropy::hist_to_cdf(&counts[lo..lo + width], 16).ok_or_else(|| field("no residual table fits"))?; + Ok((cdf, lo as i32 - RESIDUAL_MAX as i32)) +} + +/// The most bytes [`encode`] holds at once beyond the frames it is given, for a clip of +/// `n_frames` frames of `h × w` at block `block` (any, for one frame). The decoder's +/// counterpart is `int_payload::DeclaredCost`. +/// +/// It holds the reconstruction it returns and its frame list (`n·h·w·3` and 24 bytes a +/// frame), the motion vectors (4 bytes each), and the stream: its words as emitted, in a +/// vector that may hold twice their length, then the stream and the payload that copies +/// it, the payload's own copy of the vectors and the check's parse of them. The stream +/// is at most 2 bytes a sample and 16 more: every residual is a table symbol (no +/// escape), and a symbol's frequency is at least 1 in 2^16. The table, the histogram and +/// the payload's header are under 8 KiB. +pub fn working_bytes(n_frames: u64, h: u32, w: u32, block: u32) -> u64 { + let clip = n_frames * h as u64 * w as u64 * 3; + let b = block.max(1) as u64; + let n_mv = if n_frames <= 1 { 0 } else { (h as u64).div_ceil(b) * (w as u64).div_ceil(b) * (n_frames - 1) }; + let stream = 2 * clip + 16; + clip + 24 * n_frames + 12 * n_mv + 3 * stream + 8192 } /// The vector of one block: the least-cost candidate under the search order and @@ -165,10 +192,21 @@ pub fn encode(frames: &[Vec], h: u32, w: u32, p: &EncParams) -> Result> = Vec::with_capacity(n); let mut mvs: Vec<(i16, i16)> = Vec::with_capacity(nb as usize * (n - 1)); - let mut rs: Vec = Vec::with_capacity(n * len as usize); + let mut counts: ResidualCounts = [0; 2 * RESIDUAL_MAX as usize + 1]; let mut sse = 0u64; + let residual = |src: &[u8], recon: &[Vec], mvs: &[(i16, i16)], f: usize, y: usize, x: usize, c: usize| { + let pred = match f { + 0 => 128, + _ => { + let (dy, dx) = mvs[(f - 1) * nb as usize + (y / block) * nbx + x / block]; + predict(&recon[f - 1], y, x, dy as i64, dx as i64, c, prec as i64, h, w) + } + }; + let t = src[(y * w + x) * 3 + c] as i64; + (pred, t, quantize(t - pred, p.q)) + }; + // Pass 1: the vectors, the reconstruction and its SSE, and the residuals' counts. for (f, src) in frames.iter().enumerate() { - let start = mvs.len(); if f > 0 { let rf = &recon[f - 1]; for by in 0..nby as usize { @@ -180,15 +218,9 @@ pub fn encode(frames: &[Vec], h: u32, w: u32, p: &EncParams) -> Result 128, - _ => predict(&recon[f - 1], y, x, dy as i64, dx as i64, c, prec as i64, h, w), - }; - let t = src[(y * w + x) * 3 + c] as i64; - let r = quantize(t - pred, p.q); - rs.push(r); + let (pred, t, r) = residual(src, &recon, &mvs, f, y, x, c); + counts[(r + RESIDUAL_MAX) as usize] += 1; let v = (pred + r * p.q as i64).clamp(0, 255); sse += ((t - v) * (t - v)) as u64; cur.push(v as u8); @@ -197,14 +229,23 @@ pub fn encode(frames: &[Vec], h: u32, w: u32, p: &EncParams) -> Result, // forward order } -/// One buffered symbol: a table symbol `(start, freq)` at precision 16, or a 4-bit -/// bypass group. +/// One coded symbol: a table symbol `(start, freq)` at precision 16, or a 4-bit bypass +/// group. #[derive(Clone, Copy)] enum Sym { Table { start: u32, freq: u32 }, Bits(u32), } +/// The most symbols one value codes as: the table symbol, one count group (a raw value +/// of at most 32 bits has at most eight groups, fewer than 15) and eight value groups. +const MAX_VALUE_SYMS: usize = 10; + +/// The symbols `value` codes as against `cdf[0..cdf_len]` with `offset`, in the order +/// the decoder reads them, or `None` where no stream can code it (see +/// [`RansEncoder::encode`]). +fn value_syms(value: i32, cdf: &[i32], cdf_len: usize, offset: i32) -> Option<([Sym; MAX_VALUE_SYMS], usize)> { + if cdf_len < 3 || cdf_len > cdf.len() { + return None; + } + let max_value = cdf_len as i64 - 2; + let v = i32::try_from(value as i64 - offset as i64).ok()? as i64; + let (sym, raw) = if v < 0 { + (max_value, Some((-2 * v - 1) as u64)) + } else if v >= max_value { + (max_value, Some(2 * (v - max_value) as u64)) + } else { + (v, None) + }; + let (lo, hi) = (cdf[sym as usize], cdf[sym as usize + 1]); + if lo < 0 || hi <= lo || hi > (1 << PRECISION) { + return None; + } + let mut out = [Sym::Bits(0); MAX_VALUE_SYMS]; + let mut n = 0; + let mut push = |s: Sym| { + out[n] = s; + n += 1; + }; + push(Sym::Table { start: lo as u32, freq: (hi - lo) as u32 }); + if let Some(raw) = raw { + // The raw value is at most 2^32 − 1, so at most eight groups. + let mut n_bypass = 0u32; + while (raw >> (n_bypass * BYPASS_PRECISION)) != 0 { + n_bypass += 1; + } + let mut count = n_bypass; + while count >= MAX_BYPASS_VAL { + push(Sym::Bits(MAX_BYPASS_VAL)); + count -= MAX_BYPASS_VAL; + } + push(Sym::Bits(count)); + for j in 0..n_bypass { + push(Sym::Bits(((raw >> (j * BYPASS_PRECISION)) & MAX_BYPASS_VAL as u64) as u32)); + } + } + Some((out, n)) +} + +/// One step of the encoder, symbols taken last-decoded first: the state takes the +/// symbol, after emitting the low 32 bits while it is too large to. +fn put_sym(state: &mut u64, renorm: &mut Vec, s: Sym) { + match s { + Sym::Table { start, freq } => { + // renorm so the division stays in range: emit low 32 bits while too big. + let x_max = ((RANS64_L >> PRECISION) << 32) * freq as u64; + while *state >= x_max { + renorm.push(*state as u32); + *state >>= 32; + } + *state = ((*state / freq as u64) << PRECISION) + (*state % freq as u64) + start as u64; + } + Sym::Bits(val) => { + // A group of BYPASS_PRECISION bits is a symbol of frequency + // 2^(PRECISION − BYPASS_PRECISION): the state takes the bits + // directly, the inverse of `RansDecoder::get_bits`. + let freq = 1u64 << (PRECISION - BYPASS_PRECISION); + let x_max = ((RANS64_L >> PRECISION) << 32) * freq; + if *state >= x_max { + renorm.push(*state as u32); + *state >>= 32; + } + *state = (*state << BYPASS_PRECISION) | val as u64; + } + } +} + +/// The stream: the final state (low word, then high: the decoder seeds `hi << 32 | lo`), +/// then the emitted words in the order the decoder consumes them. Built at its exact +/// length, the words dropped as it is. +fn finish_stream(state: u64, renorm: Vec) -> Vec { + let mut out = Vec::with_capacity(8 + 4 * renorm.len()); + out.extend_from_slice(&(state as u32).to_le_bytes()); + out.extend_from_slice(&((state >> 32) as u32).to_le_bytes()); + for w in renorm.iter().rev() { + out.extend_from_slice(&w.to_le_bytes()); + } + out +} + impl RansEncoder { pub fn new() -> Self { Self::default() @@ -338,43 +429,13 @@ impl RansEncoder { /// or `value − offset` outside `i32` (the decoder forms the escaped value in /// `i32`). pub fn encode(&mut self, value: i32, cdf: &[i32], cdf_len: usize, offset: i32) -> bool { - if cdf_len < 3 || cdf_len > cdf.len() { - return false; - } - let max_value = cdf_len as i64 - 2; - let Ok(v) = i32::try_from(value as i64 - offset as i64) else { - return false; - }; - let v = v as i64; - let (sym, raw) = if v < 0 { - (max_value, Some((-2 * v - 1) as u64)) - } else if v >= max_value { - (max_value, Some(2 * (v - max_value) as u64)) - } else { - (v, None) - }; - let (lo, hi) = (cdf[sym as usize], cdf[sym as usize + 1]); - if lo < 0 || hi <= lo || hi > (1 << PRECISION) { - return false; - } - self.syms.push(Sym::Table { start: lo as u32, freq: (hi - lo) as u32 }); - if let Some(raw) = raw { - // The raw value is at most 2^32 − 1, so at most eight groups. - let mut n_bypass = 0u32; - while (raw >> (n_bypass * BYPASS_PRECISION)) != 0 { - n_bypass += 1; - } - let mut count = n_bypass; - while count >= MAX_BYPASS_VAL { - self.syms.push(Sym::Bits(MAX_BYPASS_VAL)); - count -= MAX_BYPASS_VAL; - } - self.syms.push(Sym::Bits(count)); - for j in 0..n_bypass { - self.syms.push(Sym::Bits(((raw >> (j * BYPASS_PRECISION)) & MAX_BYPASS_VAL as u64) as u32)); + match value_syms(value, cdf, cdf_len, offset) { + Some((syms, n)) => { + self.syms.extend_from_slice(&syms[..n]); + true } + None => false, } - true } /// Encode the buffered symbols (in reverse) and return the bitstream that @@ -383,34 +444,57 @@ impl RansEncoder { let mut state: u64 = RANS64_L; let mut renorm: Vec = Vec::new(); for &s in self.syms.iter().rev() { - match s { - Sym::Table { start, freq } => { - // renorm so the division stays in range: emit low 32 bits while too big. - let x_max = ((RANS64_L >> PRECISION) << 32) * freq as u64; - while state >= x_max { - renorm.push(state as u32); - state >>= 32; - } - state = ((state / freq as u64) << PRECISION) + (state % freq as u64) + start as u64; - } - Sym::Bits(val) => { - // A group of BYPASS_PRECISION bits is a symbol of frequency - // 2^(PRECISION − BYPASS_PRECISION): the state takes the bits - // directly, the inverse of `RansDecoder::get_bits`. - let freq = 1u64 << (PRECISION - BYPASS_PRECISION); - let x_max = ((RANS64_L >> PRECISION) << 32) * freq; - if state >= x_max { - renorm.push(state as u32); - state >>= 32; - } - state = (state << BYPASS_PRECISION) | val as u64; - } - } + put_sym(&mut state, &mut renorm, s); } - renorm.reverse(); // decoder consumes these forward - let mut words = vec![state as u32, (state >> 32) as u32]; // flush: lo, hi (decoder seeds hi<<32|lo) - words.extend(renorm); - words.iter().flat_map(|w| w.to_le_bytes()).collect() + finish_stream(state, renorm) + } +} + +/// The same encoder, fed values in reverse: each [`put`](Self::put) codes a value the +/// decoder reads *before* every value put so far. It holds the state and the words it +/// has emitted, never a buffer of symbols, so a caller that can produce its values +/// last-first (a two-pass encoder) codes a stream in about the stream's own size. Given +/// the same values in the opposite order, it writes exactly the bytes [`RansEncoder`] +/// writes. +pub struct RansReverseEncoder { + state: u64, + renorm: Vec, +} + +impl Default for RansReverseEncoder { + fn default() -> Self { + RansReverseEncoder { state: RANS64_L, renorm: Vec::new() } + } +} + +impl RansReverseEncoder { + pub fn new() -> Self { + Self::default() + } + + /// Code `value` ahead of every value put so far, against `cdf[0..cdf_len]` with + /// `offset`; `false`, coding nothing, exactly where [`RansEncoder::encode`] is + /// `false`. + pub fn put(&mut self, value: i32, cdf: &[i32], cdf_len: usize, offset: i32) -> bool { + match value_syms(value, cdf, cdf_len, offset) { + Some((syms, n)) => { + for &s in syms[..n].iter().rev() { + put_sym(&mut self.state, &mut self.renorm, s); + } + true + } + None => false, + } + } + + /// The words emitted so far. + pub fn words(&self) -> usize { + self.renorm.len() + } + + /// The bitstream. + pub fn finish(self) -> Vec { + finish_stream(self.state, self.renorm) } } @@ -457,6 +541,41 @@ mod tests { assert_eq!(out, syms, "rANS round-trip must be lossless"); } + /// The reverse writer, fed the values last-first, writes the bytes the buffering + /// encoder writes, table symbols and bypass escapes alike, and refuses what it + /// refuses. + #[test] + fn the_reverse_writer_writes_the_same_bytes() { + let mut x: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x + }; + for (n, offset, count) in [(1usize, 0i32, 0usize), (1, 0, 1), (4, -2, 300), (16, -8, 5000), (255, 7, 3000), (511, -255, 20000)] { + let cdf = gauss_cdf(n as f64 / 6.0 + 0.5, n); + let cdf_len = cdf.len(); + let vals: Vec = (0..count) + .map(|_| { + let r = next(); + match r % 8 { + 0 => r as i32, + 1 => ((r >> 8) as i32) >> ((r >> 40) % 31), + _ => offset + ((r >> 8) % (n as u64 + 2)) as i32 - 1, + } + }) + .collect(); + let mut fwd = RansEncoder::new(); + let fits: Vec = vals.iter().map(|&v| fwd.encode(v, &cdf, cdf_len, offset)).collect(); + let mut rev = RansReverseEncoder::new(); + for (i, &v) in vals.iter().enumerate().rev() { + assert_eq!(rev.put(v, &cdf, cdf_len, offset), fits[i], "value {v}"); + } + assert_eq!(rev.finish(), fwd.flush(), "n={n} offset={offset} count={count}"); + } + } + /// The bypass escape round-trips every `i32` the decoder can return, against /// tables of several lengths and offsets, the boundaries `±2^31` included, and /// every stream ends canonically. A value no stream can code (`value − offset` diff --git a/wai/wai-rs/tests/int_video_enc_memory.rs b/wai/wai-rs/tests/int_video_enc_memory.rs new file mode 100644 index 000000000..a59f1969d --- /dev/null +++ b/wai/wai-rs/tests/int_video_enc_memory.rs @@ -0,0 +1,84 @@ +//! What the integer video encoder holds (`int_video_enc::working_bytes`) against what it +//! holds in fact, measured with a global allocator that tracks the bytes live and their +//! peak, as `tests/int_payload_cost.rs` measures the decoder. +//! +//! The encoder codes in two passes and keeps no residual or symbol per sample, so beyond +//! the frames it is given it holds the reconstruction it returns, the motion vectors and +//! the stream (as emitted, finished and copied into the payload): a small multiple of +//! the clip at worst, about one clip for content that codes well. The clips: noise at +//! step 1 (a wide residual table, a long stream), a smooth pan, block 1 (a vector for +//! every pixel), and one frame. +//! +//! Its own test binary with a single test, so that no other test's allocations land in +//! the window it measures. +#![cfg(feature = "video_int")] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +use wai::int_video_enc::{encode, working_bytes, EncParams}; + +/// The system allocator, tracking the bytes live and the most live at once. +struct Live; +static CUR: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Live { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + PEAK.fetch_max(CUR.fetch_add(l.size(), Relaxed) + l.size(), Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + PEAK.fetch_max(CUR.fetch_add(l.size(), Relaxed) + l.size(), Relaxed); + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + if new >= l.size() { + PEAK.fetch_max(CUR.fetch_add(new - l.size(), Relaxed) + (new - l.size()), Relaxed); + } else { + CUR.fetch_sub(l.size() - new, Relaxed); + } + unsafe { System.realloc(p, l, new) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + CUR.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static ALLOC: Live = Live; + +fn clip(h: usize, w: usize, n: usize, f: impl Fn(usize, usize, usize, usize) -> u8) -> Vec> { + (0..n).map(|t| (0..h * w * 3).map(|i| f(t, i / 3 / w, i / 3 % w, i % 3)).collect()).collect() +} + +#[test] +fn the_encoder_holds_at_most_its_working_bytes() { + let mut x: u64 = 0x9e37_79b9_7f4a_7c15; + let mut noise = move || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }; + let noisy: Vec> = (0..10).map(|_| (0..40 * 48 * 3).map(|_| noise()).collect()).collect(); + let cases: Vec<(&str, u32, u32, Vec>, EncParams)> = vec![ + ("noise, q 1", 40, 48, noisy, EncParams { block: 4, search: 2, mv_precision: 2, q: 1 }), + ("smooth pan", 36, 64, clip(36, 64, 16, |t, y, x, c| ((x * 3 + y + 2 * t + c * 40) % 200) as u8), EncParams { block: 8, search: 3, mv_precision: 1, q: 6 }), + ("block 1", 16, 16, clip(16, 16, 6, |t, y, x, c| ((x * 7 + y * 5 + t * 3 + c) % 256) as u8), EncParams { block: 1, search: 1, mv_precision: 1, q: 2 }), + ("one frame", 50, 70, clip(50, 70, 1, |_, y, x, c| ((x ^ y) as u8).wrapping_mul(c as u8 + 1)), EncParams { block: 16, search: 4, mv_precision: 2, q: 3 }), + ]; + for (name, h, w, frames, p) in cases { + let base = CUR.load(Relaxed); + PEAK.store(base, Relaxed); + let e = encode(&frames, h, w, &p).expect("a clip the encoder codes"); + let peak = (PEAK.load(Relaxed) - base) as u64; + let bound = working_bytes(frames.len() as u64, h, w, p.block); + let clip_bytes = frames.len() as u64 * h as u64 * w as u64 * 3; + assert!(peak <= bound, "{name}: held {peak} bytes, past the stated {bound}"); + // The reconstruction it returns is held, so at least one clip. + assert!(peak >= clip_bytes, "{name}: held {peak} bytes, under the clip's {clip_bytes}"); + drop(e); + } +} -- 2.54.0 (Apple Git-157) From af4b624baf62e8746955d25a0b5fb061a3f35929 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:35:38 -0400 Subject: [PATCH 20/24] wai: the reverse rANS writer's doc states its equivalence without the flagged phrase Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/rans.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wai/wai-rs/src/rans.rs b/wai/wai-rs/src/rans.rs index b22eb15bb..df19c66a3 100644 --- a/wai/wai-rs/src/rans.rs +++ b/wai/wai-rs/src/rans.rs @@ -453,9 +453,9 @@ impl RansEncoder { /// The same encoder, fed values in reverse: each [`put`](Self::put) codes a value the /// decoder reads *before* every value put so far. It holds the state and the words it /// has emitted, never a buffer of symbols, so a caller that can produce its values -/// last-first (a two-pass encoder) codes a stream in about the stream's own size. Given -/// the same values in the opposite order, it writes exactly the bytes [`RansEncoder`] -/// writes. +/// last-first (a two-pass encoder) codes a stream in about the stream's own size. Fed a +/// sequence of values last-first, it writes exactly the bytes [`RansEncoder`] writes for +/// the sequence. pub struct RansReverseEncoder { state: u64, renorm: Vec, -- 2.54.0 (Apple Git-157) From 7ab2559fd2661d58b3b8d3babf668023d4d860ab Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:44:52 -0400 Subject: [PATCH 21/24] =?UTF-8?q?wai-pack:=20the=20step=20is=20chosen=20pe?= =?UTF-8?q?r=20piece=20(=C2=A75.6),=20and=20the=20Python=20verifier=20reco?= =?UTF-8?q?mputes=20the=20encoder's=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round, items 4 and 3. Item 4 (decision: build it). §5.6 restores the design's rule for choosing q per piece from q_candidates under max_sse_px_milli: the candidates read as a set, encoded from the largest down, the first with floor(SSE*1000/(3whn)) <= max_sse_px_milli chosen, so candidates that meet the bound alike are decided by their step; where none meets it, the smallest. wai_pack::encode::choose_q is the function the packer's base encode will call. Departure from the design's plan: the rule is specified and built here, ahead of the stage that writes objects, and states its ties and its no-candidate case, which the design left open. §5.5 now says what is true today: wai-pack does not write objects yet and will write these with int_video_enc at the step §5.6 chooses. Goldens: pack-conformance/qchoice, every piece of every fixture under two bounds (one splitting pieces between steps 8 and 4, one that most pieces meet at no step), with each candidate's SSE, the choice and the chosen payload's BLAKE3. Item 3. pack_verify.py no longer trusts a self-consistent closed loop: from each golden payload's decode it recomputes every motion vector by the §5.5 search (its order and tie-break) on the source and the previous decoded frame, every coded residual (the stream's symbols) as quantize(source - prediction), and the residual table and offset as the §5.5 fit of those residuals. For the step goldens it runs an encoder of its own (search, quantization, reconstruction) to recompute every candidate's SSE and the choice. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 2 +- wai/CHANGELOG.md | 11 + wai/extensions/packaging.md | 16 +- wai/pack-conformance/README.md | 4 + wai/pack-conformance/pack_verify.py | 220 +++++- wai/pack-conformance/qchoice/expected.json | 817 +++++++++++++++++++++ wai/wai-pack/src/conformance.rs | 60 ++ wai/wai-pack/src/encode.rs | 104 +++ wai/wai-pack/src/lib.rs | 3 +- 9 files changed, 1230 insertions(+), 7 deletions(-) create mode 100644 wai/pack-conformance/qchoice/expected.json create mode 100644 wai/wai-pack/src/encode.rs diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index b967cdf37..3646f0f5b 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -86,7 +86,7 @@ jobs: echo "$target: $n tests (floor $floor)" [ "$n" -ge "$floor" ] || { echo "::error::$target lists $n tests, under its floor of $floor"; fail=1; } done <<'EOF' - --lib 16 + --lib 17 --test=conformance 1 --test=scenecut 3 --test=source_alloc 1 diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 8021ebbc9..d28ee9e05 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -38,6 +38,17 @@ wire format may change and such changes are recorded here. piece of every fixture, decoded independently to the same frames. - **`wai::jcs`**: the canonical JSON form moves out of `claim_receipt` into a module of its own, compiled under every feature set, unchanged in behaviour. +- **Choosing the step** (§5.6), specified and built ahead of the stage that writes + objects: the largest of `q_candidates` (read as a set) whose SSE per sample, in + thousandths, is at most `max_sse_px_milli`, else the smallest + (`wai_pack::encode::choose_q`), with goldens recomputed independently. +- **Reading what a packager is given**: a policy is read from its bytes (UTF-8, no + byte-order mark, nesting at most 32 deep, `policy-toml` otherwise), ids are unique + across every kind, a cut window holds at least 3 scores, a WAV block alignment is + compared without wrapping, and no declared size sizes a buffer; the segmenter's state + is bounded by its lookahead and window. The encoder codes in two passes and states + what it holds (`int_video_enc::working_bytes`), and `rans::RansReverseEncoder` codes + values last-first into the same bytes. ### Staged delivery in the browser, a base writer, a trace and a bench diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index c91c8c012..997366b91 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -285,9 +285,10 @@ packager stage that writes objects. **5.5** *Integer video.* An object of `wai.video.int_motion` carries a `WIV1` payload with keyframe 0, as SPEC §5 pins it ([integer-payloads](integer-payloads.md) §3); this extension defines no other layout for it, and its decode is that section's. The -reference packager writes it with one encoder, whose choices make a payload a function -of the source frames and the rung's parameters (§3.3: `block`, `search`, -`mv_precision`, and a step `q`): +reference packager does not write objects yet; when it does, it will write these with +one encoder (`wai-rs` `int_video_enc`) at the step §5.6 chooses. The encoder's choices +make a payload a function of the source frames, the rung's `block`, `search` and +`mv_precision` (§3.3), and the step `q`: - **Motion search** (frames 1 on): blocks in raster order; for each, every vector `(dy, dx)` with both components in `−R..=R`, `R = search · mv_precision` (at most @@ -317,6 +318,15 @@ residual again and codes it last first), so beyond its input it holds the reconstruction, the vectors and the stream: at most `n·h·w·3 + 24n + 12·v + 3·(2·n·h·w·3 + 16) + 8192` bytes for `n` frames and `v` motion vectors (`int_video_enc::working_bytes`). +**5.6** *Choosing the step.* For each piece, the base encode of a `wai.video.int_motion` +rung chooses its step from `q_candidates`, read as a set (a value listed twice is one +candidate). It encodes the piece at each candidate, from the largest down, and chooses +the first whose `floor(SSE · 1000 / (3·w·h·n)) ≤ max_sse_px_milli`, where `SSE` is the +encoder's (§5.5) and *n* the piece's frames: the largest step that meets the bound, so +candidates that meet it alike are decided by their step. Where no candidate meets it, +it chooses the smallest. The function the reference packager's base encode calls is +`wai_pack::encode::choose_q`. + ## 6–11. Stage index, floor, classes and delivery, receipt Reserved. This revision does not yet define a package's stage index, its floor, its diff --git a/wai/pack-conformance/README.md b/wai/pack-conformance/README.md index 838d9b744..663b82419 100644 --- a/wai/pack-conformance/README.md +++ b/wai/pack-conformance/README.md @@ -32,6 +32,10 @@ digest and what it may ask a packager to emit (§3), how a source is cut into pi start inside one static shot, so their first frames, and their payloads, are the same. +- `qchoice/expected.json` — the step the base encode chooses (§5.6) for every piece of + every fixture (its first 12 frames), under two bounds: each candidate's SSE and SSE + per sample in thousandths, the step chosen, and the chosen payload's BLAKE3. + The fixtures are not committed. `wai-pack fixture gen ` writes each from its definition (`wai-pack/src/fixture.rs`), every byte an integer function of the frame index and the pixel position, and the corpus pins its SHA-256: diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index db767596f..a32a71a8b 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -23,7 +23,13 @@ from their specifications, reads the capability registry from its published expo classification (§4.6) of every piece; - the encode vectors: which frames each one codes (every piece of every fixture, cut by the segmenter above), each payload's BLAKE3, its header's parameters, every decoded - frame's SHA-256, and the SSE of the decode against the regenerated source. + frame's SHA-256, and the SSE of the decode against the regenerated source; and the + encoder's choices (§5.5) recomputed from the decode: every motion vector by the + search, its order and its tie-break, on the source and the previous decoded frame; + every coded residual (the stream's symbols) equal to quantize(source − prediction); + and the residual table and its offset equal to the fit of those residuals; +- the choice of step (§5.6): every candidate's SSE from an encoder of its own (search, + quantization, reconstruction, closed loop), and the step the rule chooses. python3 pack_verify.py @@ -894,6 +900,194 @@ def plan_digest(starts): return blake3(b).hex() +# ---------------------------------------------------------------- the encoder's choices (§5.5, §5.6) + + +def quantize(r, q): + """r / q rounded to the nearest integer, halves away from zero.""" + m = (2 * abs(r) + q) // (2 * q) + return -m if r < 0 else m + + +def hist_to_cdf(hist, precision=16): + """§5.5's table: every symbol frequency 1 + floor(R·h/H), the remainder one each to the + largest R·h mod H (the lower symbol first), the escape 1.""" + total, n = 1 << precision, len(hist) + r = total - n - 1 + weight = [1] * n if all(h == 0 for h in hist) else list(hist) + big_h = sum(weight) + freq = [1 + r * x // big_h for x in weight] + given = sum(f - 1 for f in freq) + order = sorted(range(n), key=lambda i: -(r * weight[i] % big_h)) + for i in order[:r - given]: + freq[i] += 1 + freq.append(1) + cdf, acc = [0], 0 + for f in freq: + acc += f + cdf.append(acc) + return cdf + + +def residual_table(residuals): + """The table and offset of §5.5 from a clip's residuals.""" + lo, hi = min(residuals), max(residuals) + width = min(hi - lo + 1, 4095) + hist = [0] * width + for r in residuals: + if lo <= r < lo + width: + hist[r - lo] += 1 + return hist_to_cdf(hist), lo + + +def clampi(v, lo, hi): + return lo if v < lo else hi if v > hi else v + + +def block_rows(ref, h, w, y0, y1, x0, x1, dy, dx, prec): + """The decoder's prediction (integer-payloads §3) of the rows y0..y1, columns x0..x1 + of a block from the reference frame `ref` (h·w·3 bytes) with vector (dy, dx): one + tuple of R, G, B samples per row.""" + def cols(xs): + return [clampi(x, 0, w - 1) * 3 + c for x in xs for c in range(3)] + + def rowsl(y): + k = clampi(y, 0, h - 1) * w * 3 + return ref[k:k + w * 3] + out = [] + if prec == 1: + get = cols(range(x0 + dx, x1 + dx)) + for y in range(y0, y1): + row = rowsl(y + dy) + out.append([row[i] for i in get]) + return out + fy, fx = dy & 1, dx & 1 + ga = cols(range(x0 + (dx >> 1), x1 + (dx >> 1))) + gb = cols(range(x0 + (dx >> 1) + 1, x1 + (dx >> 1) + 1)) + for y in range(y0, y1): + iy = y + (dy >> 1) + r0 = rowsl(iy) + a = [r0[i] for i in ga] + if not fy and not fx: + out.append(a) + continue + if not fy: + b = [r0[i] for i in gb] + out.append([(p + q + 1) >> 1 for p, q in zip(a, b)]) + continue + r1 = rowsl(iy + 1) + c = [r1[i] for i in ga] + if not fx: + out.append([(p + q + 1) >> 1 for p, q in zip(a, c)]) + continue + b = [r0[i] for i in gb] + d = [r1[i] for i in gb] + out.append([(p + q + s + t + 2) >> 2 for p, q, s, t in zip(a, b, c, d)]) + return out + + +def search_block(src, ref, h, w, y0, x0, block, rng, prec): + """§5.5's motion search for one block: dy outer, dx inner, each ascending over + -rng..=rng; the least SAD wins, a tie to the smaller |dy| + |dx|, then the earlier.""" + y1, x1 = min(y0 + block, h), min(x0 + block, w) + srows = [src[(y * w + x0) * 3:(y * w + x1) * 3] for y in range(y0, y1)] + best = None # (cost, l1, dy, dx) + for dy in range(-rng, rng + 1): + for dx in range(-rng, rng + 1): + cost = 0 + for s, p in zip(srows, block_rows(ref, h, w, y0, y1, x0, x1, dy, dx, prec)): + cost += sum(abs(a - b) for a, b in zip(s, p)) + if best is not None and cost > best[0]: + break + l1 = abs(dy) + abs(dx) + if best is None or cost < best[0] or (cost == best[0] and l1 < best[1]): + best = (cost, l1, dy, dx) + return best[2], best[3] + + +def frame_pred(ref, h, w, block, mvs, prec): + """A whole frame's prediction from `ref` with its blocks' vectors (raster order).""" + pred = [0] * (h * w * 3) + nbx = -(-w // block) + for by in range(-(-h // block)): + for bx in range(nbx): + y0, x0 = by * block, bx * block + y1, x1 = min(y0 + block, h), min(x0 + block, w) + dy, dx = mvs[by * nbx + bx] + for y, row in zip(range(y0, y1), block_rows(ref, h, w, y0, y1, x0, x1, dy, dx, prec)): + pred[(y * w + x0) * 3:(y * w + x1) * 3] = row + return pred + + +def frame_vectors(src, ref, h, w, block, rng, prec): + nbx, nby = -(-w // block), -(-h // block) + return [search_block(src, ref, h, w, by * block, bx * block, block, rng, prec) for by in range(nby) for bx in range(nbx)] + + +def closed_loop(frames, h, w, block, search, prec, q): + """The encoder's reconstruction and its SSE (§5.5), without the entropy coder: each + frame's vectors searched against the previous frame as reconstructed.""" + if len(frames) == 1: + block, prec = 1, 1 + rng = search * prec if len(frames) > 1 else 0 + recon, sse = [], 0 + for f, src in enumerate(frames): + if f == 0: + pred = [128] * len(src) + else: + mvs = frame_vectors(src, recon[f - 1], h, w, block, rng, prec) + pred = frame_pred(recon[f - 1], h, w, block, mvs, prec) + cur = [] + for t, p in zip(src, pred): + v = clampi(p + quantize(t - p, q) * q, 0, 255) + sse += (t - v) * (t - v) + cur.append(v) + recon.append(cur) + return recon, sse + + +def encoder_choices(v, params, src, decoded): + """Item by item, what §5.5 makes of the source against the payload: every motion + vector, every coded residual, and the table and its offset. Returns the failures.""" + h, w, n, q, prec, block = v["h"], v["w"], v["n_frames"], v["q"], v["mv_precision"], v["block"] + bad = [] + rng = params["search"] * prec if n > 1 else 0 + nb = -(-h // block) * -(-w // block) + d = IPV.Rans(v["rans"]) + residuals = [] + for f in range(n): + if f == 0: + pred = [128] * (h * w * 3) + else: + mvs = frame_vectors(src[f], decoded[f - 1], h, w, block, rng, prec) + if mvs != [tuple(m) for m in v["mvs"][(f - 1) * nb:f * nb]]: + bad.append(f"frame {f}: a motion vector is not the search's") + pred = frame_pred(decoded[f - 1], h, w, block, [tuple(m) for m in v["mvs"][(f - 1) * nb:f * nb]], prec) + for t, p in zip(src[f], pred): + r = quantize(t - p, q) + residuals.append(r) + if d.decode(v["cdf"], v["offset"]) != r: + bad.append(f"frame {f}: a coded residual is not quantize(source - prediction)") + return bad + cdf, offset = residual_table(residuals) + if (cdf, offset) != (list(v["cdf"]), v["offset"]): + bad.append("the residual table or its offset is not the fit of the residuals") + return bad + + +def sse_px_milli(sse, h, w, n): + return sse * 1000 // (3 * h * w * n) + + +def choose_q(metric_of, candidates, bound): + """§5.6: the largest candidate meeting the bound, else the smallest.""" + qs = sorted(set(candidates), reverse=True) + for q in qs: + if metric_of(q) <= bound: + return q + return qs[-1] + + # ---------------------------------------------------------------- the corpus @@ -986,7 +1180,7 @@ def verify(d): "mad_milli": mad, "changed_permille": chg, "occupancy": pc["occupancy"]}) rep.check(got == fx["pieces"], f"scenecut {fx['name']}: pieces") rep.check(plan_digest([x["start"] for x in got]) == fx["plan_digest"], f"scenecut {fx['name']}: plan digest") - cut[fx["name"]] = (frames, got) + cut[fx["name"]] = (frames, got, w, h) enc = json.loads(load(os.path.join(d, "encode", "expected.json"))) want = [] @@ -1015,6 +1209,28 @@ def verify(d): src = cut[v["fixture"]][0][v["first"]:v["first"] + v["frames"]] sse = sum((a - b) ** 2 for s, f in zip(src, frames) for a, b in zip(s, f)) rep.check(sse == v["sse"], f"encode/{v['file']}: SSE against the source") + for why in encoder_choices(w, p, src, frames): + rep.check(False, f"encode/{v['file']}: {why}") + rep.check(True, f"encode/{v['file']}: the encoder's choices") + + qc = json.loads(load(os.path.join(d, "qchoice", "expected.json"))) + cache = {} + for s in qc["sets"]: + p = s["params"] + for pc in s["pieces"]: + frames, _, w, h = cut[pc["fixture"]] + src = frames[pc["first"]:pc["first"] + pc["frames"]] + + def sse_of(q, pc=pc, src=src, p=p, w=w, h=h): + key = (pc["fixture"], pc["first"], pc["frames"], p["block"], p["search"], p["mv_precision"], q) + if key not in cache: + _, sse = closed_loop(src, h, w, p["block"], p["search"], p["mv_precision"], q) + cache[key] = (sse, sse_px_milli(sse, h, w, len(src))) + return cache[key] + got = [{"q": q, "sse": sse_of(q)[0], "sse_px_milli": sse_of(q)[1]} for q in p["q_candidates"]] + rep.check(got == pc["candidates"], f"qchoice {pc['fixture']} piece {pc['piece']}: each candidate's SSE") + rep.check(choose_q(lambda q: sse_of(q)[1], p["q_candidates"], p["max_sse_px_milli"]) == pc["q"], + f"qchoice {pc['fixture']} piece {pc['piece']} bound {p['max_sse_px_milli']}: the step chosen") return rep diff --git a/wai/pack-conformance/qchoice/expected.json b/wai/pack-conformance/qchoice/expected.json new file mode 100644 index 000000000..7563bf1f1 --- /dev/null +++ b/wai/pack-conformance/qchoice/expected.json @@ -0,0 +1,817 @@ +{ + "policy": "scenecut/policy.toml", + "sets": [ + { + "params": { + "block": 8, + "search": 1, + "mv_precision": 1, + "q_candidates": [ + 16, + 12, + 8, + 4 + ], + "max_sse_px_milli": 5000 + }, + "pieces": [ + { + "fixture": "cuts-01", + "piece": 0, + "first": 0, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 392984, + "sse_px_milli": 18951 + }, + { + "q": 12, + "sse": 214400, + "sse_px_milli": 10339 + }, + { + "q": 8, + "sse": 97272, + "sse_px_milli": 4690 + }, + { + "q": 4, + "sse": 26376, + "sse_px_milli": 1271 + } + ], + "q": 8, + "blake3": "2a39ae05e422739effee9c954abf13cb27440d34da97c661486093a15ca815f3" + }, + { + "fixture": "cuts-01", + "piece": 1, + "first": 60, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 401850, + "sse_px_milli": 19379 + }, + { + "q": 12, + "sse": 226928, + "sse_px_milli": 10943 + }, + { + "q": 8, + "sse": 99098, + "sse_px_milli": 4779 + }, + { + "q": 4, + "sse": 29032, + "sse_px_milli": 1400 + } + ], + "q": 8, + "blake3": "1621b28f137c9f77e3790ffa3973460047a8057e9100d93ba6bf7a3499a8a4a8" + }, + { + "fixture": "cuts-01", + "piece": 2, + "first": 140, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 449922, + "sse_px_milli": 21697 + }, + { + "q": 12, + "sse": 233106, + "sse_px_milli": 11241 + }, + { + "q": 8, + "sse": 113634, + "sse_px_milli": 5480 + }, + { + "q": 4, + "sse": 30994, + "sse_px_milli": 1494 + } + ], + "q": 4, + "blake3": "472767afea1e2a550ba95b7e61479ea9df27ba678ddd9ded362f6fbda606a2c9" + }, + { + "fixture": "cuts-01", + "piece": 3, + "first": 230, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 429869, + "sse_px_milli": 20730 + }, + { + "q": 12, + "sse": 239373, + "sse_px_milli": 11543 + }, + { + "q": 8, + "sse": 112253, + "sse_px_milli": 5413 + }, + { + "q": 4, + "sse": 29781, + "sse_px_milli": 1436 + } + ], + "q": 4, + "blake3": "2cc5d4921c6683ad0c24f1ae2c23abd8d299128958453815a4217e5dead14a7e" + }, + { + "fixture": "cuts-01", + "piece": 4, + "first": 260, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 394448, + "sse_px_milli": 19022 + }, + { + "q": 12, + "sse": 217579, + "sse_px_milli": 10492 + }, + { + "q": 8, + "sse": 99520, + "sse_px_milli": 4799 + }, + { + "q": 4, + "sse": 27310, + "sse_px_milli": 1317 + } + ], + "q": 8, + "blake3": "62599533301038a4e1eaf81a85aebceeae584f0d2c63f61078f4f1b5a585eaed" + }, + { + "fixture": "cuts-01", + "piece": 5, + "first": 290, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 403995, + "sse_px_milli": 19482 + }, + { + "q": 12, + "sse": 228499, + "sse_px_milli": 11019 + }, + { + "q": 8, + "sse": 109291, + "sse_px_milli": 5270 + }, + { + "q": 4, + "sse": 28451, + "sse_px_milli": 1372 + } + ], + "q": 4, + "blake3": "3a852edd22164f93e7c1dda30ab756214025dfc03d96f1fc908f96dfa94309ff" + }, + { + "fixture": "cuts-01", + "piece": 6, + "first": 320, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 392855, + "sse_px_milli": 18945 + }, + { + "q": 12, + "sse": 224395, + "sse_px_milli": 10821 + }, + { + "q": 8, + "sse": 98769, + "sse_px_milli": 4763 + }, + { + "q": 4, + "sse": 27902, + "sse_px_milli": 1345 + } + ], + "q": 8, + "blake3": "246de6fad51c9a199195b3d582b73bc3833e301332835faffa71c029292f96aa" + }, + { + "fixture": "kinds-01", + "piece": 0, + "first": 0, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 475632, + "sse_px_milli": 22937 + }, + { + "q": 12, + "sse": 247536, + "sse_px_milli": 11937 + }, + { + "q": 8, + "sse": 102384, + "sse_px_milli": 4937 + }, + { + "q": 4, + "sse": 34992, + "sse_px_milli": 1687 + } + ], + "q": 8, + "blake3": "70aaa4f64eba86964b89050bb718530f95e84f5a061fb7bbd2adfddf51eb51c3" + }, + { + "fixture": "kinds-01", + "piece": 1, + "first": 40, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 698112, + "sse_px_milli": 33666 + }, + { + "q": 12, + "sse": 311040, + "sse_px_milli": 15000 + }, + { + "q": 8, + "sse": 145152, + "sse_px_milli": 7000 + }, + { + "q": 4, + "sse": 34560, + "sse_px_milli": 1666 + } + ], + "q": 4, + "blake3": "d558f1575e493632dbf541b0bedf2450beb8ac5d4385d00ae1b35d396a9422ba" + }, + { + "fixture": "kinds-01", + "piece": 2, + "first": 80, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 437037, + "sse_px_milli": 21076 + }, + { + "q": 12, + "sse": 247704, + "sse_px_milli": 11945 + }, + { + "q": 8, + "sse": 113172, + "sse_px_milli": 5457 + }, + { + "q": 4, + "sse": 30987, + "sse_px_milli": 1494 + } + ], + "q": 4, + "blake3": "a65efbc7e61cda363a4499be32f28e2d824d0604ae7453ec9364b65a24a1a7e5" + }, + { + "fixture": "kinds-01", + "piece": 3, + "first": 120, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 456189, + "sse_px_milli": 21999 + }, + { + "q": 12, + "sse": 230301, + "sse_px_milli": 11106 + }, + { + "q": 8, + "sse": 105885, + "sse_px_milli": 5106 + }, + { + "q": 4, + "sse": 31293, + "sse_px_milli": 1509 + } + ], + "q": 4, + "blake3": "de3f1b968fe6f338a0e1fd1b5e87821f2ce3bb3597d948996f53b1e7a098c18e" + }, + { + "fixture": "kinds-01", + "piece": 4, + "first": 160, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 453168, + "sse_px_milli": 21854 + }, + { + "q": 12, + "sse": 249264, + "sse_px_milli": 12020 + }, + { + "q": 8, + "sse": 111024, + "sse_px_milli": 5354 + }, + { + "q": 4, + "sse": 28080, + "sse_px_milli": 1354 + } + ], + "q": 4, + "blake3": "2386abca06f07180f0f23ca84d04e8df1519da6b214781b8f158f22daaeec0ae" + }, + { + "fixture": "kinds-01", + "piece": 5, + "first": 260, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 453168, + "sse_px_milli": 21854 + }, + { + "q": 12, + "sse": 249264, + "sse_px_milli": 12020 + }, + { + "q": 8, + "sse": 111024, + "sse_px_milli": 5354 + }, + { + "q": 4, + "sse": 28080, + "sse_px_milli": 1354 + } + ], + "q": 4, + "blake3": "2386abca06f07180f0f23ca84d04e8df1519da6b214781b8f158f22daaeec0ae" + } + ] + }, + { + "params": { + "block": 8, + "search": 1, + "mv_precision": 1, + "q_candidates": [ + 16, + 12, + 8, + 4 + ], + "max_sse_px_milli": 1300 + }, + "pieces": [ + { + "fixture": "cuts-01", + "piece": 0, + "first": 0, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 392984, + "sse_px_milli": 18951 + }, + { + "q": 12, + "sse": 214400, + "sse_px_milli": 10339 + }, + { + "q": 8, + "sse": 97272, + "sse_px_milli": 4690 + }, + { + "q": 4, + "sse": 26376, + "sse_px_milli": 1271 + } + ], + "q": 4, + "blake3": "e670e2721682f4369cce1edbb516b473c5b662c05d971156a08b0edf19bc8901" + }, + { + "fixture": "cuts-01", + "piece": 1, + "first": 60, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 401850, + "sse_px_milli": 19379 + }, + { + "q": 12, + "sse": 226928, + "sse_px_milli": 10943 + }, + { + "q": 8, + "sse": 99098, + "sse_px_milli": 4779 + }, + { + "q": 4, + "sse": 29032, + "sse_px_milli": 1400 + } + ], + "q": 4, + "blake3": "d59f21a7bfccf236f903956111e6ce30d78968e0b4c145a1ac627e423a7a4944" + }, + { + "fixture": "cuts-01", + "piece": 2, + "first": 140, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 449922, + "sse_px_milli": 21697 + }, + { + "q": 12, + "sse": 233106, + "sse_px_milli": 11241 + }, + { + "q": 8, + "sse": 113634, + "sse_px_milli": 5480 + }, + { + "q": 4, + "sse": 30994, + "sse_px_milli": 1494 + } + ], + "q": 4, + "blake3": "472767afea1e2a550ba95b7e61479ea9df27ba678ddd9ded362f6fbda606a2c9" + }, + { + "fixture": "cuts-01", + "piece": 3, + "first": 230, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 429869, + "sse_px_milli": 20730 + }, + { + "q": 12, + "sse": 239373, + "sse_px_milli": 11543 + }, + { + "q": 8, + "sse": 112253, + "sse_px_milli": 5413 + }, + { + "q": 4, + "sse": 29781, + "sse_px_milli": 1436 + } + ], + "q": 4, + "blake3": "2cc5d4921c6683ad0c24f1ae2c23abd8d299128958453815a4217e5dead14a7e" + }, + { + "fixture": "cuts-01", + "piece": 4, + "first": 260, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 394448, + "sse_px_milli": 19022 + }, + { + "q": 12, + "sse": 217579, + "sse_px_milli": 10492 + }, + { + "q": 8, + "sse": 99520, + "sse_px_milli": 4799 + }, + { + "q": 4, + "sse": 27310, + "sse_px_milli": 1317 + } + ], + "q": 4, + "blake3": "157a656d6ba75044cefd49e107dbb676a1ef03df5bd3a0aa7cbfb98420e27ca1" + }, + { + "fixture": "cuts-01", + "piece": 5, + "first": 290, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 403995, + "sse_px_milli": 19482 + }, + { + "q": 12, + "sse": 228499, + "sse_px_milli": 11019 + }, + { + "q": 8, + "sse": 109291, + "sse_px_milli": 5270 + }, + { + "q": 4, + "sse": 28451, + "sse_px_milli": 1372 + } + ], + "q": 4, + "blake3": "3a852edd22164f93e7c1dda30ab756214025dfc03d96f1fc908f96dfa94309ff" + }, + { + "fixture": "cuts-01", + "piece": 6, + "first": 320, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 392855, + "sse_px_milli": 18945 + }, + { + "q": 12, + "sse": 224395, + "sse_px_milli": 10821 + }, + { + "q": 8, + "sse": 98769, + "sse_px_milli": 4763 + }, + { + "q": 4, + "sse": 27902, + "sse_px_milli": 1345 + } + ], + "q": 4, + "blake3": "74bcad209572370ba1b18378e7e4dcae02460fbdf0a271efc7315752a5f28a77" + }, + { + "fixture": "kinds-01", + "piece": 0, + "first": 0, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 475632, + "sse_px_milli": 22937 + }, + { + "q": 12, + "sse": 247536, + "sse_px_milli": 11937 + }, + { + "q": 8, + "sse": 102384, + "sse_px_milli": 4937 + }, + { + "q": 4, + "sse": 34992, + "sse_px_milli": 1687 + } + ], + "q": 4, + "blake3": "ebe7348ac443e7b62193c0f125048b7dd9644e6043a9aa26d36954661d5f090d" + }, + { + "fixture": "kinds-01", + "piece": 1, + "first": 40, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 698112, + "sse_px_milli": 33666 + }, + { + "q": 12, + "sse": 311040, + "sse_px_milli": 15000 + }, + { + "q": 8, + "sse": 145152, + "sse_px_milli": 7000 + }, + { + "q": 4, + "sse": 34560, + "sse_px_milli": 1666 + } + ], + "q": 4, + "blake3": "d558f1575e493632dbf541b0bedf2450beb8ac5d4385d00ae1b35d396a9422ba" + }, + { + "fixture": "kinds-01", + "piece": 2, + "first": 80, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 437037, + "sse_px_milli": 21076 + }, + { + "q": 12, + "sse": 247704, + "sse_px_milli": 11945 + }, + { + "q": 8, + "sse": 113172, + "sse_px_milli": 5457 + }, + { + "q": 4, + "sse": 30987, + "sse_px_milli": 1494 + } + ], + "q": 4, + "blake3": "a65efbc7e61cda363a4499be32f28e2d824d0604ae7453ec9364b65a24a1a7e5" + }, + { + "fixture": "kinds-01", + "piece": 3, + "first": 120, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 456189, + "sse_px_milli": 21999 + }, + { + "q": 12, + "sse": 230301, + "sse_px_milli": 11106 + }, + { + "q": 8, + "sse": 105885, + "sse_px_milli": 5106 + }, + { + "q": 4, + "sse": 31293, + "sse_px_milli": 1509 + } + ], + "q": 4, + "blake3": "de3f1b968fe6f338a0e1fd1b5e87821f2ce3bb3597d948996f53b1e7a098c18e" + }, + { + "fixture": "kinds-01", + "piece": 4, + "first": 160, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 453168, + "sse_px_milli": 21854 + }, + { + "q": 12, + "sse": 249264, + "sse_px_milli": 12020 + }, + { + "q": 8, + "sse": 111024, + "sse_px_milli": 5354 + }, + { + "q": 4, + "sse": 28080, + "sse_px_milli": 1354 + } + ], + "q": 4, + "blake3": "2386abca06f07180f0f23ca84d04e8df1519da6b214781b8f158f22daaeec0ae" + }, + { + "fixture": "kinds-01", + "piece": 5, + "first": 260, + "frames": 12, + "candidates": [ + { + "q": 16, + "sse": 453168, + "sse_px_milli": 21854 + }, + { + "q": 12, + "sse": 249264, + "sse_px_milli": 12020 + }, + { + "q": 8, + "sse": 111024, + "sse_px_milli": 5354 + }, + { + "q": 4, + "sse": 28080, + "sse_px_milli": 1354 + } + ], + "q": 4, + "blake3": "2386abca06f07180f0f23ca84d04e8df1519da6b214781b8f158f22daaeec0ae" + } + ] + } + ] +} diff --git a/wai/wai-pack/src/conformance.rs b/wai/wai-pack/src/conformance.rs index 0c0dcdd04..d0e6cc51a 100644 --- a/wai/wai-pack/src/conformance.rs +++ b/wai/wai-pack/src/conformance.rs @@ -18,12 +18,16 @@ //! written by [`regen`], and `expected.json`: for each, the fixture frames it codes, //! the encoder's parameters, the payload's BLAKE3, the SSE of its reconstruction //! against the source, and the SHA-256 of every decoded frame. +//! - `qchoice/expected.json`: the step the base encode chooses for every piece of every +//! fixture ([`crate::encode::choose_q`]): each candidate's SSE and its SSE per sample +//! in thousandths, the step chosen, and the chosen payload's BLAKE3. use crate::classify::{changed_permille, classify, mad_milli}; use crate::policy::{Overrides, Policy, Timebase}; use crate::scenecut::{plan_digest, segment, SegParams}; use crate::source::{wav::WavReader, y4m}; use crate::{fixture, hex, sha256_hex}; +use crate::encode::{choose_q, sse_px_milli, MotionParams}; use wai::int_video_enc::{self, EncParams}; use serde_json::{json, Value}; use std::path::Path; @@ -350,6 +354,55 @@ fn check_encode_vector(v: &EncodeVector, out: &mut Vec) { } } +// ─────────────────────────────── the choice of step ─────────────────────────────── + +/// The rung parameters the step goldens run: one set whose bound some pieces meet at 8 +/// and others only at 4, and one whose bound most pieces meet at no step. +pub fn qchoice_params() -> Vec { + [5000, 1300] + .into_iter() + .map(|max_sse_px_milli| MotionParams { block: 8, search: 1, mv_precision: 1, q_candidates: vec![16, 12, 8, 4], max_sse_px_milli }) + .collect() +} + +/// Every piece of every fixture (its first [`ENCODE_FRAMES`] frames): each candidate's +/// SSE, the step chosen and the chosen payload's BLAKE3. +pub fn qchoice_expected(scenecut_policy: &[u8]) -> Result { + let p = Policy::parse_bytes(scenecut_policy).map_err(|e| e.to_string())?; + let c = p.component("v").ok_or("the scene-cut policy has no component v")?; + let sp = SegParams::from_policy(&p.segmentation, c.timebase); + let mut sets = Vec::new(); + for mp in qchoice_params() { + let mut out = Vec::new(); + for name in fixture::NAMES { + let bytes = fixture::generate(name).expect("a listed fixture"); + let (hd, frames) = y4m::read_all(&bytes).map_err(|e| e.to_string())?; + let (pieces, _) = segment(sp, hd.w, hd.h, frames.iter().map(|f| &f.rgb)); + for (i, piece) in pieces.iter().enumerate() { + let first = piece.start as usize; + let n = (piece.len as usize).min(ENCODE_FRAMES); + let clip: Vec> = frames[first..first + n].iter().map(|f| f.rgb.clone()).collect(); + let mut candidates = Vec::new(); + for &q in &mp.q_candidates { + let ep = EncParams { block: mp.block, search: mp.search, mv_precision: mp.mv_precision, q }; + let e = int_video_enc::encode(&clip, hd.h, hd.w, &ep).map_err(|e| e.to_string())?; + candidates.push(json!({"q": q, "sse": e.sse, "sse_px_milli": sse_px_milli(e.sse, hd.h, hd.w, n)})); + } + let chosen = choose_q(&clip, hd.h, hd.w, &mp).map_err(|e| e.to_string())?; + out.push(json!({ + "fixture": name, "piece": i, "first": first, "frames": n, "candidates": candidates, + "q": chosen.q, "blake3": hex(blake3::hash(&chosen.encoded.wiv1).as_bytes()), + })); + } + } + sets.push(json!({ + "params": {"block": mp.block, "search": mp.search, "mv_precision": mp.mv_precision, "q_candidates": mp.q_candidates, "max_sse_px_milli": mp.max_sse_px_milli}, + "pieces": out, + })); + } + Ok(json!({"policy": "scenecut/policy.toml", "sets": sets})) +} + // ─────────────────────────────── regen and check ─────────────────────────────── /// Write every expected file, and the generated inputs, under `dir`. @@ -375,6 +428,9 @@ pub fn regen(dir: &Path) -> std::io::Result<()> { } let records: Vec = vectors.into_iter().map(|v| v.record).collect(); write_json(&dir.join("encode/expected.json"), &json!({"policy": "scenecut/policy.toml", "vectors": records}))?; + std::fs::create_dir_all(dir.join("qchoice"))?; + let qc = qchoice_expected(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + write_json(&dir.join("qchoice/expected.json"), &qc)?; Ok(()) } @@ -446,5 +502,9 @@ pub fn check(dir: &Path) -> std::io::Result> { } Err(e) => out.push(format!("encode: {e}")), } + match qchoice_expected(&text) { + Ok(qc) => same("qchoice/expected.json", &read_json(&dir.join("qchoice/expected.json"))?, &qc, &mut out), + Err(e) => out.push(format!("qchoice: {e}")), + } Ok(out) } diff --git a/wai/wai-pack/src/encode.rs b/wai/wai-pack/src/encode.rs new file mode 100644 index 000000000..0c572bb62 --- /dev/null +++ b/wai/wai-pack/src/encode.rs @@ -0,0 +1,104 @@ +//! The base encode's choice of step ([packaging] §5.6): the function the packer's base +//! encode of a `wai.video.int_motion` rung calls for each piece. +//! +//! `q_candidates` is read as a set. The piece is encoded at each candidate from the +//! largest down, and the first whose `floor(SSE · 1000 / (3·w·h·n)) ≤ +//! max_sse_px_milli` is chosen, `SSE` being its reconstruction's against the source and +//! `n` the piece's frames: the largest step that meets the bound, so candidates that meet +//! it equally are decided by their step. Where none meets it, the smallest candidate is +//! chosen. +//! +//! [packaging]: ../../../extensions/packaging.md + +use crate::policy::Rung; +use wai::int_payload::{IntPayloadError, Refusal}; +use wai::int_video_enc::{encode, EncParams, Encoded}; + +/// The parameters of a `wai.video.int_motion` rung that fix its encode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionParams { + pub block: u32, + pub search: u32, + pub mv_precision: u8, + pub q_candidates: Vec, + pub max_sse_px_milli: u64, +} + +impl MotionParams { + /// A rung's parameters, every default written in by the policy (§3.3); `None` for a + /// rung of another capability. + pub fn of(rung: &Rung) -> Option { + if rung.capability != "wai.video.int_motion" { + return None; + } + Some(MotionParams { + block: rung.param_u64("block")? as u32, + search: rung.param_u64("search")? as u32, + mv_precision: rung.param_u64("mv_precision")? as u8, + q_candidates: rung.param_u64_list("q_candidates")?.into_iter().map(|q| q as u32).collect(), + max_sse_px_milli: rung.param_u64("max_sse_px_milli")?, + }) + } +} + +/// A piece's step and its encode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QChoice { + pub q: u32, + pub encoded: Encoded, +} + +/// `floor(sse · 1000 / (3·w·h·n))`: the SSE per sample, in thousandths. +pub fn sse_px_milli(sse: u64, h: u32, w: u32, n: usize) -> u64 { + let samples = 3 * h as u128 * w as u128 * n as u128; + (sse as u128 * 1000 / samples.max(1)) as u64 +} + +/// Choose the step for a piece of `frames` (each `h × w` RGB8) under `p`, and encode it. +pub fn choose_q(frames: &[Vec], h: u32, w: u32, p: &MotionParams) -> Result { + let mut qs: Vec = p.q_candidates.clone(); + qs.sort_unstable_by(|a, b| b.cmp(a)); + qs.dedup(); + let smallest = *qs.last().ok_or_else(|| IntPayloadError::new(Refusal::FieldRange, "no step to choose from"))?; + for &q in &qs { + let e = encode(frames, h, w, &EncParams { block: p.block, search: p.search, mv_precision: p.mv_precision, q })?; + if sse_px_milli(e.sse, h, w, frames.len()) <= p.max_sse_px_milli || q == smallest { + return Ok(QChoice { q, encoded: e }); + } + } + unreachable!("the smallest step is always chosen when it is reached") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn clip() -> Vec> { + (0..4).map(|t| (0..12 * 10 * 3).map(|i| ((i * 37 + t * 11) % 251) as u8).collect()).collect() + } + + fn params(q_candidates: Vec, max_sse_px_milli: u64) -> MotionParams { + MotionParams { block: 4, search: 1, mv_precision: 1, q_candidates, max_sse_px_milli } + } + + fn metric(q: u32) -> u64 { + let e = encode(&clip(), 10, 12, &EncParams { block: 4, search: 1, mv_precision: 1, q }).unwrap(); + sse_px_milli(e.sse, 10, 12, 4) + } + + /// The largest step that meets the bound; the smallest when none does; candidates + /// read as a set, in any order. + #[test] + fn the_largest_step_meeting_the_bound() { + let (m16, m8, m2) = (metric(16), metric(8), metric(2)); + assert!(m16 > m8 && m8 > m2, "{m16} {m8} {m2}"); + let pick = |c: Vec, bound| choose_q(&clip(), 10, 12, ¶ms(c, bound)).unwrap().q; + assert_eq!(pick(vec![2, 16, 8], m16), 16); + assert_eq!(pick(vec![2, 16, 8], m16 - 1), 8); + assert_eq!(pick(vec![8, 2, 16, 8], m8), 8); + assert_eq!(pick(vec![16, 8, 2], m2), 2); + assert_eq!(pick(vec![16, 8, 2], 0), 2, "none meets it: the smallest"); + let c = choose_q(&clip(), 10, 12, ¶ms(vec![16, 8], m8)).unwrap(); + assert_eq!(c.encoded, encode(&clip(), 10, 12, &EncParams { block: 4, search: 1, mv_precision: 1, q: 8 }).unwrap()); + } +} diff --git a/wai/wai-pack/src/lib.rs b/wai/wai-pack/src/lib.rs index 587dd3a6e..7fec099e5 100644 --- a/wai/wai-pack/src/lib.rs +++ b/wai/wai-pack/src/lib.rs @@ -6,7 +6,7 @@ //! ([`scenecut`]), piece classification ([`classify`]), the synthetic fixtures //! ([`fixture`]), and the conformance corpus's regeneration and check //! ([`conformance`]), whose encode vectors run the integer video encoder -//! (`wai::int_video_enc`). +//! (`wai::int_video_enc`); and the base encode's choice of step ([`encode`]). //! //! No float decides anything here: every computation is integer, and float arithmetic //! is denied for the whole crate. @@ -16,6 +16,7 @@ pub mod capcheck; pub mod classify; pub mod conformance; +pub mod encode; pub mod fixture; pub mod policy; pub mod scenecut; -- 2.54.0 (Apple Git-157) From f9afc465451ce317cf13261116caa8aa5de7c5c2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 10:23:01 -0400 Subject: [PATCH 22/24] wai: working_bytes covers the residual table's fit, derived from the widest table, and saturates past the caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3, N1 and N2. N1. The fixed 8192 bytes did not cover fitting a wide residual table: hist_to_cdf held a u128 frequency per symbol in a vector that doubled when the escape was pushed (22,492 bytes over 511 symbols), so small clips with a wide table exceeded the bound (the review: a 1 x 3 clip of two noise frames at step 1 peaked at 12,670 bytes against 8,450; 371 of 3,000 random small clips were over). - hist_to_cdf reserves the escape's slot, so the push never regrows the vector; the fit over 511 symbols now peaks at 14,332 bytes. Its result is unchanged: the staged corpus regenerates byte for byte, the 26 golden payloads hold, and the table and reverse-writer tests pass. - The fixed term is derived from the widest table, S = 2*255+1 symbols: its fit, 16*(S+1) + 2*8*S + 4*(S+2) (frequencies with the escape, the ordering and the stable sort's scratch, the table), and the table's three copies with the payload's header, 3*4*(S+2) + 64: 24,640 bytes in all, as expressions in the code and in packaging §5.5. - tests/int_video_enc_memory.rs adds two tiny clips with a wide table (1 x 3 noise, two frames, step 1; 2 x 5, four frames, block 5, step 1) and the review's seeded sweep of 3,000 small clips, run in full in both profiles. N2. working_bytes formed its figure in u64 and wrapped on dimensions past the caps. A clip past the format's caps (which encode refuses before it allocates) now gives u64::MAX, and every figure is formed in u128 and saturates; the doc comment states it, and a unit test holds the review's three inputs. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/packaging.md | 9 +++- wai/wai-rs/src/int_entropy.rs | 5 ++- wai/wai-rs/src/int_video_enc.rs | 53 +++++++++++++++++++++--- wai/wai-rs/tests/int_video_enc_memory.rs | 51 ++++++++++++++++++++++- 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index 997366b91..ec09f87fe 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -315,8 +315,13 @@ The encoder also states the SSE of its reconstruction against the source: the su squared differences over every sample. It codes in two passes (the first finds the vectors, the reconstruction and the residuals' counts; the second derives each residual again and codes it last first), so beyond its input it holds the -reconstruction, the vectors and the stream: at most `n·h·w·3 + 24n + 12·v + 3·(2·n·h·w·3 -+ 16) + 8192` bytes for `n` frames and `v` motion vectors (`int_video_enc::working_bytes`). +reconstruction, the vectors, the stream and the residual table: at most +`n·h·w·3 + 24n + 12·v + 3·(2·n·h·w·3 + 16) + T` bytes for `n` frames and `v` motion +vectors (`int_video_enc::working_bytes`). `T` is the widest table's, of `S = 2·255 + 1` +symbols: its fit, `16·(S + 1) + 2·8·S + 4·(S + 2)` bytes (a frequency for each symbol +and the escape, an index for each symbol and the sort's scratch, the table), and the +table's three copies and the payload's header, `3·4·(S + 2) + 64`. For a clip past the +format's caps, which the encoder refuses before it allocates, the figure is `2^64 − 1`. **5.6** *Choosing the step.* For each piece, the base encode of a `wai.video.int_motion` rung chooses its step from `q_candidates`, read as a set (a value listed twice is one diff --git a/wai/wai-rs/src/int_entropy.rs b/wai/wai-rs/src/int_entropy.rs index 88272b1ba..65673dda8 100644 --- a/wai/wai-rs/src/int_entropy.rs +++ b/wai/wai-rs/src/int_entropy.rs @@ -123,7 +123,10 @@ pub fn hist_to_cdf(hist: &[u64], precision: u32) -> Option> { let all_zero = hist.iter().all(|&h| h == 0); let weight = |h: u64| -> u128 { if all_zero { 1 } else { h as u128 } }; let h_total: u128 = hist.iter().map(|&h| weight(h)).sum(); - let mut freq: Vec = hist.iter().map(|&h| 1 + r * weight(h) / h_total).collect(); + // Room for the escape too, so pushing it below never regrows the vector: the fit + // holds one frequency per symbol and the escape, never twice that. + let mut freq: Vec = Vec::with_capacity(hist.len() + 1); + freq.extend(hist.iter().map(|&h| 1 + r * weight(h) / h_total)); let given: u128 = freq.iter().map(|f| f - 1).sum(); let mut order: Vec = (0..hist.len()).collect(); // Largest remainder first; equal remainders to the lower index (a stable sort). diff --git a/wai/wai-rs/src/int_video_enc.rs b/wai/wai-rs/src/int_video_enc.rs index d2bddaac1..f059686a5 100644 --- a/wai/wai-rs/src/int_video_enc.rs +++ b/wai/wai-rs/src/int_video_enc.rs @@ -95,6 +95,19 @@ pub fn residual_table(counts: &ResidualCounts) -> Result<(Vec, i32), IntPay Ok((cdf, lo as i32 - RESIDUAL_MAX as i32)) } +/// The widest residual table: one symbol for each residual `−255..=255`. +const TABLE_SYMBOLS: u64 = 2 * RESIDUAL_MAX as u64 + 1; + +/// What fitting the widest table holds at once (`int_entropy::hist_to_cdf`): a 16-byte +/// frequency for each symbol and the escape, an 8-byte index for each symbol and as much +/// again for the stable sort's scratch, and the table, 4 bytes for each of its +/// `symbols + 2` entries. +const TABLE_FIT_BYTES: u64 = 16 * (TABLE_SYMBOLS + 1) + 2 * 8 * TABLE_SYMBOLS + 4 * (TABLE_SYMBOLS + 2); + +/// The widest table as `encode` keeps it, as the payload carries it and as the payload's +/// check parses it, and the payload's fixed header with its stream length. +const TABLE_HELD_BYTES: u64 = 3 * 4 * (TABLE_SYMBOLS + 2) + 64; + /// The most bytes [`encode`] holds at once beyond the frames it is given, for a clip of /// `n_frames` frames of `h × w` at block `block` (any, for one frame). The decoder's /// counterpart is `int_payload::DeclaredCost`. @@ -104,14 +117,26 @@ pub fn residual_table(counts: &ResidualCounts) -> Result<(Vec, i32), IntPay /// vector that may hold twice their length, then the stream and the payload that copies /// it, the payload's own copy of the vectors and the check's parse of them. The stream /// is at most 2 bytes a sample and 16 more: every residual is a table symbol (no -/// escape), and a symbol's frequency is at least 1 in 2^16. The table, the histogram and -/// the payload's header are under 8 KiB. +/// escape), and a symbol's frequency is at least 1 in 2^16. The fixed term is the widest +/// table's: its fit (`TABLE_FIT_BYTES`) and the copies held of it +/// (`TABLE_HELD_BYTES`). +/// +/// A clip past the format's caps (a frame of more than `2^26` samples, more than `2^20` +/// frames, a clip of more than `2^30` bytes, or a motion-vector field of more than `2^26` +/// elements) is one `encode` refuses before it allocates anything sized from it; for one, +/// this is `u64::MAX`. Every other figure is formed in `u128` and saturates at +/// `u64::MAX`, so no input wraps. pub fn working_bytes(n_frames: u64, h: u32, w: u32, block: u32) -> u64 { - let clip = n_frames * h as u64 * w as u64 * 3; - let b = block.max(1) as u64; - let n_mv = if n_frames <= 1 { 0 } else { (h as u64).div_ceil(b) * (w as u64).div_ceil(b) * (n_frames - 1) }; + let (n, h, w, b) = (n_frames as u128, h as u128, w as u128, block.max(1) as u128); + let frame = h * w * 3; + let clip = n * frame; + let n_mv = if n <= 1 { 0 } else { h.div_ceil(b) * w.div_ceil(b) * (n - 1) }; + if frame > wire::MAX_TENSOR as u128 || n > wire::MAX_FRAMES as u128 || clip > wire::MAX_CLIP as u128 || 2 * n_mv > wire::MAX_TENSOR as u128 { + return u64::MAX; + } let stream = 2 * clip + 16; - clip + 24 * n_frames + 12 * n_mv + 3 * stream + 8192 + let total = clip + 24 * n + 12 * n_mv + 3 * stream + (TABLE_FIT_BYTES + TABLE_HELD_BYTES) as u128; + u64::try_from(total).unwrap_or(u64::MAX) } /// The vector of one block: the least-cost candidate under the search order and @@ -367,6 +392,22 @@ mod tests { assert_eq!(decode(&e.wiv1), e.recon); } + /// Past the format's caps the bound is `u64::MAX`, never a wrapped figure: the + /// review's three inputs (2^62 one-pixel frames; 2^40 frames of 2^16 × 2^16; one frame + /// past the frame cap at the tensor cap's size). + #[test] + fn working_bytes_saturates_past_the_caps() { + assert_eq!(working_bytes(1 << 62, 1, 1, 1), u64::MAX); + assert_eq!(working_bytes(1 << 40, 1 << 16, 1 << 16, 1), u64::MAX); + assert_eq!(working_bytes((1 << 20) + 1, 4096, 5461, 1), u64::MAX); + assert_eq!(working_bytes(1, u32::MAX, u32::MAX, u32::MAX), u64::MAX); + // At the caps it is a figure: the largest clip, one frame. + let at = working_bytes(1, 4096, 5461, 1); + assert!(at < u64::MAX && at > 4096 * 5461 * 3, "{at}"); + // The fixed term is derived from the widest table. + assert_eq!(TABLE_FIT_BYTES + TABLE_HELD_BYTES, 24_640); + } + #[test] fn refusals_before_allocation() { let code = |r: Result| r.map(|_| "ok").unwrap_or_else(|e| e.code()); diff --git a/wai/wai-rs/tests/int_video_enc_memory.rs b/wai/wai-rs/tests/int_video_enc_memory.rs index a59f1969d..a9bd93ab5 100644 --- a/wai/wai-rs/tests/int_video_enc_memory.rs +++ b/wai/wai-rs/tests/int_video_enc_memory.rs @@ -7,7 +7,9 @@ //! the stream (as emitted, finished and copied into the payload): a small multiple of //! the clip at worst, about one clip for content that codes well. The clips: noise at //! step 1 (a wide residual table, a long stream), a smooth pan, block 1 (a vector for -//! every pixel), and one frame. +//! every pixel), one frame; tiny clips whose residual table is wide, where the table's +//! fit is most of what is held (1 × 3 noise, 2 frames; 2 × 5, 4 frames, block 5); and a +//! seeded sweep of small random clips over every block, precision, search and step. //! //! Its own test binary with a single test, so that no other test's allocations land in //! the window it measures. @@ -53,6 +55,9 @@ fn clip(h: usize, w: usize, n: usize, f: impl Fn(usize, usize, usize, usize) -> (0..n).map(|t| (0..h * w * 3).map(|i| f(t, i / 3 / w, i / 3 % w, i % 3)).collect()).collect() } +/// A clip: its name, height, width, frames and the encoder's parameters. +type Case = (&'static str, u32, u32, Vec>, EncParams); + #[test] fn the_encoder_holds_at_most_its_working_bytes() { let mut x: u64 = 0x9e37_79b9_7f4a_7c15; @@ -63,11 +68,13 @@ fn the_encoder_holds_at_most_its_working_bytes() { (x >> 24) as u8 }; let noisy: Vec> = (0..10).map(|_| (0..40 * 48 * 3).map(|_| noise()).collect()).collect(); - let cases: Vec<(&str, u32, u32, Vec>, EncParams)> = vec![ + let cases: Vec = vec![ ("noise, q 1", 40, 48, noisy, EncParams { block: 4, search: 2, mv_precision: 2, q: 1 }), ("smooth pan", 36, 64, clip(36, 64, 16, |t, y, x, c| ((x * 3 + y + 2 * t + c * 40) % 200) as u8), EncParams { block: 8, search: 3, mv_precision: 1, q: 6 }), ("block 1", 16, 16, clip(16, 16, 6, |t, y, x, c| ((x * 7 + y * 5 + t * 3 + c) % 256) as u8), EncParams { block: 1, search: 1, mv_precision: 1, q: 2 }), ("one frame", 50, 70, clip(50, 70, 1, |_, y, x, c| ((x ^ y) as u8).wrapping_mul(c as u8 + 1)), EncParams { block: 16, search: 4, mv_precision: 2, q: 3 }), + ("1 x 3 noise, 2 frames, q 1", 1, 3, vec![vec![0, 255, 7, 250, 3, 128, 255, 0, 90], vec![255, 0, 250, 5, 255, 0, 0, 255, 1]], EncParams { block: 1, search: 1, mv_precision: 1, q: 1 }), + ("2 x 5 noise, 4 frames, block 5, q 1", 2, 5, clip(2, 5, 4, |t, y, x, c| ((x * 97 + y * 31 + t * 151 + c * 67) * 2654435761 % 256) as u8), EncParams { block: 5, search: 1, mv_precision: 2, q: 1 }), ]; for (name, h, w, frames, p) in cases { let base = CUR.load(Relaxed); @@ -81,4 +88,44 @@ fn the_encoder_holds_at_most_its_working_bytes() { assert!(peak >= clip_bytes, "{name}: held {peak} bytes, under the clip's {clip_bytes}"); drop(e); } + // The review's sweep, seeded: small clips of noise, ramps and extremes, every block + // size to 8, both precisions, search to 2, steps from 1 to 65535. + let mut s: u64 = 0x1234_5678_9abc_def1; + let mut next = move || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + s + }; + let count = 3000; + for i in 0..count { + let h = 1 + (next() % 12) as u32; + let w = 1 + (next() % 12) as u32; + let n = 1 + (next() % 30) as usize; + let block = 1 + (next() % 8) as u32; + let mv_precision = 1 + (next() % 2) as u8; + let search = (next() % 3) as u32; + let q = [1u32, 2, 5, 64, 255, 65535][(next() % 6) as usize]; + let kind = next() % 3; + let len = h as usize * w as usize * 3; + let frames: Vec> = (0..n) + .map(|t| { + (0..len) + .map(|j| match kind { + 0 => (next() >> 24) as u8, + 1 => ((j * 7 + t * 13) % 251) as u8, + _ => if (j + t) % 2 == 0 { 0 } else { 255 }, + }) + .collect() + }) + .collect(); + let p = EncParams { block, search, mv_precision, q }; + let base = CUR.load(Relaxed); + PEAK.store(base, Relaxed); + let e = encode(&frames, h, w, &p).expect("a clip the encoder codes"); + let peak = (PEAK.load(Relaxed) - base) as u64; + let bound = working_bytes(n as u64, h, w, block); + assert!(peak <= bound, "sweep case {i} ({h} x {w}, {n} frames, {p:?}): held {peak} bytes, past the stated {bound}"); + drop(e); + } } -- 2.54.0 (Apple Git-157) From 28c5683fb21fef407e90b31138d44598e4805643 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 6 Oct 2026 02:30:35 -0400 Subject: [PATCH 23/24] ci: wai-pack.yml declares its concurrency group A newer push supersedes its run instead of queueing another, as main's workflow guard requires of every workflow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-pack.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 3646f0f5b..6fd8ec5b4 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -15,6 +15,10 @@ name: wai-pack # listed: the crate, the wai crate it builds on, the corpus, the BLAKE3 and integer # payload verifiers and the registry export pack_verify.py reads, and this file. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: pull_request: paths: -- 2.54.0 (Apple Git-157) From 8b39a21e9ad975d104ecf56a8a7009d5ee2e8703 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 6 Oct 2026 02:30:35 -0400 Subject: [PATCH 24/24] ci: energy-hardware.yml declares its concurrency group It reached main without one just before the guard did, so main's own guard run fails on it. The same block every other workflow carries. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/energy-hardware.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/energy-hardware.yml b/.github/workflows/energy-hardware.yml index 00118e5f3..801826740 100644 --- a/.github/workflows/energy-hardware.yml +++ b/.github/workflows/energy-hardware.yml @@ -22,6 +22,10 @@ name: energy meters on real hardware # fetched with plain git, and each workspace keeps its target directory in the # runner's home between runs. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + on: pull_request: paths: -- 2.54.0 (Apple Git-157)