From d2dc637c776a6c7bd4cafa502a5f53a33154e0a5 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Mon, 12 Oct 2026 21:19:01 -0700 Subject: [PATCH 001/118] Add craft-time: exact media timebase and SMPTE timecode (refs #2) Implements the shared exact media timebase crate (craft-time) for FilmCraft, SoundCraft, EffectCraft, and animation timeline consumers: - Exact Signed Timebase: Tick represents exact media time across [Tick::MIN, Tick::MAX] using TICKS_PER_SECOND = 254_016_000_000, supporting exact integer conversions for standard film, broadcast, audio, and high-framerate rates. - Total Arithmetic Contract: All operations are strictly panic-free under #![deny(clippy::panic)], providing checked_* methods and monotonic saturating division. - Timecode and Film Footage: Full SMPTE 12M drop-frame and non-drop-frame formatting, 35mm and 16mm feet+frames, broadcast zero-padded hours, and EffectCraft unpadded hours. - Differential Testing: 15 test suites incorporating full FilmCraft and EffectCraft parity vectors, boundary saturation tests, and proptest monotonicity invariants. --- Cargo.lock | 133 +++++- Cargo.toml | 1 + README.md | 2 +- crates/time/Cargo.toml | 17 + crates/time/README.md | 110 +++++ crates/time/src/lib.rs | 710 ++++++++++++++++++++++++++++++ crates/time/tests/differential.rs | 315 +++++++++++++ 7 files changed, 1280 insertions(+), 8 deletions(-) create mode 100644 crates/time/Cargo.toml create mode 100644 crates/time/README.md create mode 100644 crates/time/src/lib.rs create mode 100644 crates/time/tests/differential.rs diff --git a/Cargo.lock b/Cargo.lock index 6471411f0..89478f3cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", ] [[package]] @@ -215,6 +224,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -254,6 +273,30 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "brotli" version = "8.0.4" @@ -668,6 +711,14 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "craft-time" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1918,7 +1969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -2327,7 +2378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -3090,12 +3141,38 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set 0.11.1", + "bit-vec 0.10.1", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -3215,6 +3292,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3328,6 +3414,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3548,6 +3640,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -4327,7 +4431,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -4604,6 +4708,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-langid" version = "0.9.6" @@ -4823,6 +4933,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -5137,8 +5256,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -5210,7 +5329,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 868e568dd..cf90e022a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ craft-i18n = { path = "crates/i18n" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } +craft-time = { path = "crates/time" } craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } rayon = "1" diff --git a/README.md b/README.md index 78b243407..2bfd050a9 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,11 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Tick(i64::MAX)`), preserving strict mathematical monotonicity across the entire signed 64-bit range without inflection. + +## Guarantees & Doctrines + +- **Zero App Code**: Zero dependencies on application crates. +- **Never Crash**: `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- **Total Arithmetic**: Division by zero or hostile rates from damaged projects return well-defined fallback states rather than panicking. Arithmetic saturates at 64-bit limits with 128-bit intermediate checks. +- **Forbid Unsafe**: `#![forbid(unsafe_code)]`. diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs new file mode 100644 index 000000000..002eccb38 --- /dev/null +++ b/crates/time/src/lib.rs @@ -0,0 +1,710 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Exact media time for the Craft suite. +//! +//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That +//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50, +//! 59.94, 60, 119.88, 120 fps) and standard audio sample rates (44.1 kHz, 48 kHz, and their +//! multiples), so edits, frame math, audio alignment, and visual effects never drift. +//! +//! Timecode (SMPTE drop/non-drop, frames, feet+frames, audio samples) is a display representation +//! of ticks. Every conversion is total: invalid, zero, or negative rates produce well-defined defaults +//! rather than division-by-zero panics. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign}; + +/// Clock ticks per second: 254 016 000 000. +pub const TICKS_PER_SECOND: i64 = 254_016_000_000; + +/// A point or duration in time, in ticks. +/// Arithmetic saturates at storage limits; representable results remain exact. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Tick(pub i64); + +impl Tick { + pub const ZERO: Tick = Tick(0); + pub const MAX: Tick = Tick(i64::MAX / 4); + pub const MIN: Tick = Tick(i64::MIN / 4); + + /// Seconds to ticks, clamped to [`Tick::MIN`]..=[`Tick::MAX`]. + pub fn from_seconds_f64(s: f64) -> Tick { + let t = (s * TICKS_PER_SECOND as f64).round(); + Tick((t as i64).clamp(Tick::MIN.0, Tick::MAX.0)) + } + + pub fn seconds(self) -> f64 { + self.0 as f64 / TICKS_PER_SECOND as f64 + } + + /// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000). + pub fn from_units(units: i64, per_second: i64) -> Tick { + Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0))) + } + + /// Floor conversion to a count of units at `per_second`. + pub fn to_units_floor(self, per_second: i64) -> i64 { + clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128)) + } + + /// Conversion from a rational timestamp `pts * num / den` seconds (container timebases). + pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick { + if den == 0 { + return Tick::ZERO; + } + let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else { + return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX }); + }; + Tick(clamp_i64(t.div_euclid(den as i128))) + } + + /// Inverse of [`Tick::from_rational`], floored to the timebase. + pub fn to_rational_floor(self, num: i64, den: i64) -> i64 { + clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0)) + } + + /// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up). + pub fn to_rational_round(self, num: i64, den: i64) -> i64 { + let n = self.0 as i128 * den as i128; + let unit = num as i128 * TICKS_PER_SECOND as i128; + let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) }; + let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 }; + clamp_i64(if r * 2 >= unit { q + 1 } else { q }) + } + + pub fn abs(self) -> Tick { + Tick(self.0.saturating_abs()) + } + + pub fn signum(self) -> i64 { + self.0.signum() + } + + pub fn min(self, o: Tick) -> Tick { + Tick(self.0.min(o.0)) + } + + pub fn max(self, o: Tick) -> Tick { + Tick(self.0.max(o.0)) + } + + pub fn clamp(self, lo: Tick, hi: Tick) -> Tick { + Tick(self.0.max(lo.0).min(hi.0)) + } + + /// Scale by a rational factor (`num/den`), flooring. + pub fn mul_ratio(self, num: i64, den: i64) -> Tick { + Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0))) + } + + /// Divide by a rational factor (`num/den`), flooring. + pub fn div_ratio(self, num: i64, den: i64) -> Tick { + self.mul_ratio(den, num) + } + + /// Checked addition. Returns `None` if overflow occurred. + pub fn checked_add(self, rhs: Tick) -> Option { + self.0.checked_add(rhs.0).map(Tick) + } + + /// Checked subtraction. Returns `None` if overflow occurred. + pub fn checked_sub(self, rhs: Tick) -> Option { + self.0.checked_sub(rhs.0).map(Tick) + } + + /// Checked multiplication. Returns `None` if overflow occurred. + pub fn checked_mul(self, rhs: i64) -> Option { + self.0.checked_mul(rhs).map(Tick) + } + + /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick::MIN / -1`). + pub fn checked_div(self, rhs: i64) -> Option { + self.0.checked_div(rhs).map(Tick) + } + + /// Checked remainder. Returns `None` if `rhs.0 == 0` or if overflow occurred (`Tick::MIN % Tick(-1)`). + pub fn checked_rem(self, rhs: Tick) -> Option { + self.0.checked_rem(rhs.0).map(Tick) + } + + /// Saturating addition. + pub fn saturating_add(self, rhs: Tick) -> Tick { + Tick(self.0.saturating_add(rhs.0)) + } + + /// Saturating subtraction. + pub fn saturating_sub(self, rhs: Tick) -> Tick { + Tick(self.0.saturating_sub(rhs.0)) + } + + /// Saturating multiplication. + pub fn saturating_mul(self, rhs: i64) -> Tick { + Tick(self.0.saturating_mul(rhs)) + } + + /// Saturating division. Returns `None` if `rhs == 0`, and saturates on overflow (`Tick::MIN / -1 -> Tick(i64::MAX)`). + pub fn saturating_div(self, rhs: i64) -> Option { + if rhs == 0 { None } else { Some(Tick(self.0.saturating_div(rhs))) } + } +} + +impl Add for Tick { + type Output = Tick; + fn add(self, o: Tick) -> Tick { + Tick(self.0.saturating_add(o.0)) + } +} + +impl Sub for Tick { + type Output = Tick; + fn sub(self, o: Tick) -> Tick { + Tick(self.0.saturating_sub(o.0)) + } +} + +impl Neg for Tick { + type Output = Tick; + fn neg(self) -> Tick { + Tick(self.0.saturating_neg()) + } +} + +impl AddAssign for Tick { + fn add_assign(&mut self, o: Tick) { + self.0 = self.0.saturating_add(o.0); + } +} + +impl SubAssign for Tick { + fn sub_assign(&mut self, o: Tick) { + self.0 = self.0.saturating_sub(o.0); + } +} + +impl Mul for Tick { + type Output = Tick; + fn mul(self, rhs: i64) -> Tick { + Tick(self.0.saturating_mul(rhs)) + } +} + +impl Div for Tick { + type Output = Tick; + fn div(self, rhs: i64) -> Tick { + // Operator division policy: Div saturates on overflow (e.g. Tick::MIN / -1 -> Tick(i64::MAX)) + // using self.0.saturating_div(rhs) to preserve monotonicity across the full range. + // Division by zero yields Tick::ZERO. For fallible division that detects zero divisors, + // use Tick::checked_div(). + if rhs == 0 { Tick::ZERO } else { Tick(self.0.saturating_div(rhs)) } + } +} + +impl Rem for Tick { + type Output = Tick; + fn rem(self, rhs: Tick) -> Tick { + // Operator remainder policy: Rem produces exact remainder where defined, + // and Tick::ZERO for zero divisor or Tick::MIN % Tick(-1) (which is mathematically 0). + // For fallible remainder, use Tick::checked_rem(). + if rhs.0 == 0 || (self.0 == i64::MIN && rhs.0 == -1) { Tick::ZERO } else { Tick(self.0 % rhs.0) } + } +} + +impl fmt::Display for Tick { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ticks", self.0) + } +} + +/// A half-open time range `[start, start + duration)`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TimeRange { + pub start: Tick, + pub duration: Tick, +} + +impl TimeRange { + pub fn new(start: Tick, duration: Tick) -> Self { + Self { start, duration } + } + + pub fn from_bounds(start: Tick, end: Tick) -> Self { + Self { start, duration: end - start } + } + + pub fn end(&self) -> Tick { + self.start + self.duration + } + + pub fn contains(&self, t: Tick) -> bool { + t >= self.start && t < self.end() + } + + pub fn overlaps(&self, o: &TimeRange) -> bool { + self.start < o.end() && o.start < self.end() + } + + pub fn intersect(&self, o: &TimeRange) -> Option { + let s = self.start.max(o.start); + let e = self.end().min(o.end()); + (e > s).then(|| TimeRange::from_bounds(s, e)) + } + + pub fn is_empty(&self) -> bool { + self.duration.0 <= 0 + } +} + +/// A frame rate as an exact rational (`num / den` frames per second). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FrameRate { + pub num: i64, + pub den: i64, +} + +impl Default for FrameRate { + fn default() -> Self { + FrameRate::FPS_23_976 + } +} + +impl FrameRate { + pub const FPS_23_976: FrameRate = FrameRate { num: 24000, den: 1001 }; + pub const FPS_24: FrameRate = FrameRate { num: 24, den: 1 }; + pub const FPS_25: FrameRate = FrameRate { num: 25, den: 1 }; + pub const FPS_29_97: FrameRate = FrameRate { num: 30000, den: 1001 }; + pub const FPS_30: FrameRate = FrameRate { num: 30, den: 1 }; + pub const FPS_48: FrameRate = FrameRate { num: 48, den: 1 }; + pub const FPS_50: FrameRate = FrameRate { num: 50, den: 1 }; + pub const FPS_59_94: FrameRate = FrameRate { num: 60000, den: 1001 }; + pub const FPS_60: FrameRate = FrameRate { num: 60, den: 1 }; + pub const FPS_119_88: FrameRate = FrameRate { num: 120000, den: 1001 }; + pub const FPS_120: FrameRate = FrameRate { num: 120, den: 1 }; + + /// Standard broadcast and cinematic rates. + pub const COMMON: [FrameRate; 11] = [ + Self::FPS_23_976, + Self::FPS_24, + Self::FPS_25, + Self::FPS_29_97, + Self::FPS_30, + Self::FPS_48, + Self::FPS_50, + Self::FPS_59_94, + Self::FPS_60, + Self::FPS_119_88, + Self::FPS_120, + ]; + + pub fn new(num: i64, den: i64) -> Self { + let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1)); + FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 } + } + + /// Closest standard rate for a floating-point frame rate. + pub fn from_f64(fps: f64) -> Self { + for r in Self::COMMON { + if (r.as_f64() - fps).abs() < 0.005 { + return r; + } + } + FrameRate::new((fps * 1000.0).round() as i64, 1000) + } + + /// Returns this frame rate if positive, or the default rate ([`FrameRate::FPS_23_976`]). + /// + /// ### Application Compatibility Note + /// For non-positive or zero rates (e.g. `{0, 0}`), `craft-time` falls back to + /// [`FrameRate::FPS_23_976`] (timecode base 24), providing a standard broadcast default. + /// In contrast, EffectCraft falls back to timecode base 1. + pub fn sane(self) -> FrameRate { + if self.num > 0 && self.den > 0 { self } else { FrameRate::default() } + } + + pub fn as_f64(self) -> f64 { + self.num as f64 / self.den as f64 + } + + /// Exact duration of one frame (floored for exotic rates). + pub fn frame_duration(self) -> Tick { + let r = self.sane(); + Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128)) + } + + /// Index of the frame containing `t` (floor). + pub fn frame_at(self, t: Tick) -> i64 { + clamp_i64(frame_index(self.sane(), t)) + } + + /// Start tick of frame `f` (ceiling division). + pub fn tick_of(self, f: i64) -> Tick { + Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) + } + + /// Snap `t` down to a frame boundary. + pub fn snap(self, t: Tick) -> Tick { + let r = self.sane(); + Tick(clamp_i64(frame_boundary(r, frame_index(r, t)))) + } + + /// Snap `t` to the nearest frame boundary. + pub fn snap_nearest(self, t: Tick) -> Tick { + let r = self.sane(); + let f = frame_index(r, t); + let a = frame_boundary(r, f); + let b = frame_boundary(r, f.saturating_add(1)); + let t = i128::from(t.0); + Tick(clamp_i64(if t - a <= b - t { a } else { b })) + } + + /// Timecode base (nominal frames per second): 30 for 29.97, 24 for 23.976. + pub fn timecode_base(self) -> i64 { + let r = self.sane(); + (r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1) + } + + pub fn is_ntsc(self) -> bool { + self.den == 1001 + } + + pub fn supports_drop_frame(self) -> bool { + self.is_ntsc() && self.timecode_base() % 30 == 0 + } + + pub fn label(self) -> String { + if self.den == 1 { + format!("{}", self.num) + } else { + let v = self.as_f64(); + let s = format!("{v:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + } +} + +impl fmt::Display for FrameRate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} fps", self.label()) + } +} + +fn clamp_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + +fn frame_index(rate: FrameRate, t: Tick) -> i128 { + (i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den)) +} + +fn frame_boundary(rate: FrameRate, frame: i128) -> i128 { + let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else { + return if frame < 0 { i128::MIN } else { i128::MAX }; + }; + let d = i128::from(rate.num); + n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0) +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + (a, b) = (b, a % b); + } + a +} + +/// How time is formatted for display. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TimeDisplay { + #[default] + Timecode, + Frames, + Feet16, + Feet35, + AudioSamples, + Seconds, +} + +impl TimeDisplay { + pub const ALL: [TimeDisplay; 6] = + [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds]; + + pub fn label(self) -> &'static str { + match self { + TimeDisplay::Timecode => "Timecode", + TimeDisplay::Frames => "Frames", + TimeDisplay::Feet16 => "Feet + Frames 16mm", + TimeDisplay::Feet35 => "Feet + Frames 35mm", + TimeDisplay::AudioSamples => "Audio Samples", + TimeDisplay::Seconds => "Seconds", + } + } +} + +fn df_params(rate: FrameRate) -> (i64, i64) { + let base = rate.timecode_base(); + (base / 15, base) +} + +/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`. +pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) { + let neg = frame < 0; + let mut n = i128::from(frame).abs(); + let (drop, base) = df_params(rate); + let (drop, base) = (i128::from(drop), i128::from(base)); + if drop_frame && rate.supports_drop_frame() { + let per_min = base * 60 - drop; + let per_10 = per_min * 10 + drop; + let d = n / per_10; + let m = n % per_10; + n = n.saturating_add(drop * 9 * d); + if m > drop { + n = n.saturating_add(drop * ((m - drop) / per_min)); + } + } + let f = n % base; + let s = (n / base) % 60; + let mi = (n / (base * 60)) % 60; + let h = n / (base * 3600); + (neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f)) +} + +/// Convert SMPTE fields back to a frame count. +pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 { + let (drop, base) = df_params(rate); + let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s); + let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f)); + if drop_frame && rate.supports_drop_frame() { + let total_min = i128::from(h) * 60 + i128::from(m); + n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10)); + } + clamp_i64(n) +} + +/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame). +pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> String { + let df = drop_frame && rate.supports_drop_frame(); + let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); + let sep = if df { ';' } else { ':' }; + let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; + format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) +} + +/// Format a frame count the way After Effects / EffectCraft displays the current time: hours not zero-padded +/// (`0:00:02:15`, or `0;00;02;15` for drop-frame). +pub fn format_timecode_ae(frame: i64, rate: FrameRate, drop_frame: bool) -> String { + let df = drop_frame && rate.supports_drop_frame(); + let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); + let sep = if df { ';' } else { ':' }; + let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; + format!("{}{h}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) +} + +/// Format a tick according to a display mode. +pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { + let frame = rate.frame_at(t); + match display { + TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame), + TimeDisplay::Frames => format!("{frame}"), + TimeDisplay::Feet35 | TimeDisplay::Feet16 => { + let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 }; + let neg = frame < 0; + let a = frame.unsigned_abs(); + format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) + } + TimeDisplay::AudioSamples => { + let secs = t.0.div_euclid(TICKS_PER_SECOND); + let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate); + format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60) + } + TimeDisplay::Seconds => format!("{:.3}", t.seconds()), + } +} + +/// Format a frame count as Feet + Frames (film footage counting). +pub fn format_feet_frames(frame: i64, per_foot: i64) -> String { + let pf = per_foot.max(1); + let sign = if frame < 0 { "-" } else { "" }; + let a = frame.unsigned_abs(); + let pf = pf as u64; + let w = if pf > 100 { 3 } else { 2 }; + format!("{sign}{:04}+{:0w$}", a / pf, a % pf) +} + +/// Error from timecode parsing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError(pub String); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for ParseError {} + +/// Parse user-typed timecode into a frame count. +/// +/// Supports standard formats (`HH:MM:SS:FF`, `HH;MM;SS;FF`), short entry (`1000` -> `00:00:10:00`), +/// and relative offsets (`+15`, `-1.00`). +/// +/// ### Application Compatibility Note +/// For oversized timecodes whose frame calculations exceed 64-bit limits, `craft-time` +/// saturates to `i64::MAX` (or `i64::MIN`) to keep calculations total without errors. +/// In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`. +pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err(ParseError("empty timecode".into())); + } + let (rel, body) = match s.as_bytes()[0] { + b'+' => (Some(1), &s[1..]), + b'-' => (Some(-1), &s[1..]), + _ => (None, s), + }; + let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); + let nums: Vec = if parts.len() == 1 { + let digits = parts[0]; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(ParseError(format!("not a timecode: `{input}`"))); + } + if rel.is_some() { + vec![digits.parse().map_err(|_| ParseError("number too large".into()))?] + } else { + let mut v = Vec::new(); + let b = digits.as_bytes(); + let mut end = b.len(); + while end > 0 { + let start = end.saturating_sub(2); + v.push(digits[start..end].parse::().unwrap_or(0)); + end = start; + } + v.reverse(); + v + } + } else { + parts + .iter() + .map(|p| if p.is_empty() { Ok(0) } else { p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) }) + .collect::>()? + }; + if nums.len() > 4 { + return Err(ParseError("too many timecode fields".into())); + } + let mut f4 = [0i64; 4]; + let off = 4 - nums.len(); + f4[off..].copy_from_slice(&nums); + let [h, m, sec, fr] = f4; + let base = rate.timecode_base(); + let frames = if nums.len() == 1 && rel.is_some() { + fr + } else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base { + fields_to_frames(h, m, sec, fr, rate, true) + } else { + h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) + }; + Ok(match rel { + Some(sign) => current.saturating_add(frames.saturating_mul(sign)), + None => frames, + }) +} + +/// Parse a Feet + Frames field into a frame count. +pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result { + let s = input.trim(); + let bad = || ParseError(format!("not feet+frames: `{input}`")); + let num = |t: &str| -> Result { + let t = t.trim(); + if t.is_empty() { + return Ok(0); + } + if !t.bytes().all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + t.parse::().map_err(|_| ParseError("number too large".into())) + }; + if s.is_empty() { + return Err(ParseError("empty feet+frames".into())); + } + let (sign, body) = match s.as_bytes()[0] { + b'-' => (-1, &s[1..]), + b'+' => (1, &s[1..]), + _ => (0, s), + }; + match body.rfind('+') { + Some(i) => { + let (feet, frames) = (num(&body[..i])?, num(&body[i + 1..])?); + let v = feet.checked_mul(per_foot.max(1)).and_then(|v| v.checked_add(frames)).ok_or_else(|| ParseError("number too large".into()))?; + Ok(if sign < 0 { -v } else { v }) + } + None => { + let v = num(body)?; + Ok(match sign { + 0 => v, + -1 => current.saturating_sub(v), + _ => current.saturating_add(v), + }) + } + } +} + +/// Common audio sample rates offered in sequence settings. +pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tick_conversions() { + assert_eq!(Tick::from_seconds_f64(1.0).0, TICKS_PER_SECOND); + assert_eq!(Tick::from_units(48000, 48000).0, TICKS_PER_SECOND); + assert_eq!(Tick(TICKS_PER_SECOND).to_units_floor(48000), 48000); + assert_eq!(Tick::from_rational(1, 1, 1).0, TICKS_PER_SECOND); + assert_eq!(Tick(TICKS_PER_SECOND).to_rational_floor(1, 1), 1); + assert_eq!(Tick(TICKS_PER_SECOND).to_rational_round(1, 1), 1); + } + + #[test] + fn test_hostile_rates_never_panic() { + let zero = FrameRate { num: 0, den: 0 }; + for r in [zero, FrameRate { num: 0, den: 1 }, FrameRate { num: 25, den: 0 }, FrameRate { num: -30, den: 1 }] { + assert_eq!(r.tick_of(10), FrameRate::default().tick_of(10)); + assert_eq!(r.frame_at(Tick(TICKS_PER_SECOND)), FrameRate::default().frame_at(Tick(TICKS_PER_SECOND))); + assert!(r.frame_duration() > Tick::ZERO); + assert!(r.timecode_base() >= 1); + let _ = r.snap_nearest(Tick(12345)); + let _ = format_time(Tick(TICKS_PER_SECOND), r, true, TimeDisplay::Timecode, 48_000); + } + } + + #[test] + fn test_broadcast_frame_rates() { + for r in FrameRate::COMMON { + assert_eq!(r.frame_at(r.tick_of(100)), 100); + assert_eq!(r.snap(r.tick_of(50)), r.tick_of(50)); + assert_eq!(r.snap_nearest(r.tick_of(50)), r.tick_of(50)); + } + } + + #[test] + fn test_smpte_timecode_roundtrip() { + for r in [FrameRate::FPS_24, FrameRate::FPS_25, FrameRate::FPS_30, FrameRate::FPS_29_97] { + for f in [0, 1, 24, 25, 30, 100, 1800, 54000] { + let df = r.supports_drop_frame(); + let tc = format_timecode_frames(f, r, df); + let parsed = parse_timecode(&tc, r, df, 0); + assert_eq!(parsed, Ok(f), "rate: {}, frame: {}, tc: {}", r, f, tc); + } + } + } + + #[test] + fn test_feet_frames_roundtrip() { + for pf in [16, 40] { + for f in [0, 1, 15, 16, 95, 199, 1234] { + let s = format_feet_frames(f, pf); + let parsed = parse_feet_frames(&s, pf, 0); + assert_eq!(parsed, Ok(f)); + } + } + } +} diff --git a/crates/time/tests/differential.rs b/crates/time/tests/differential.rs new file mode 100644 index 000000000..f4634c230 --- /dev/null +++ b/crates/time/tests/differential.rs @@ -0,0 +1,315 @@ +use craft_time::{ + FrameRate, SAMPLE_RATES, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, format_feet_frames, format_time, format_timecode_ae, + format_timecode_frames, frames_to_fields, parse_feet_frames, parse_timecode, +}; +use proptest::prelude::*; + +#[test] +fn ticks_per_second_divides_all_standard_rates_evenly() { + assert_eq!(TICKS_PER_SECOND, 254_016_000_000); + + // Divisible by all standard frame rate numerators and denominators + for r in FrameRate::COMMON { + let den = r.den as i128; + let num = r.num as i128; + let product = TICKS_PER_SECOND as i128 * den; + assert_eq!(product % num, 0, "Rate {} ({} / {}) does not divide TICKS_PER_SECOND evenly", r.label(), r.num, r.den); + } + + // Audio sample rates + for &sr in &SAMPLE_RATES { + assert_eq!(TICKS_PER_SECOND % sr, 0, "Sample rate {sr} does not divide TICKS_PER_SECOND evenly"); + } +} + +#[test] +fn frame_roundtrip_all_common_rates() { + for r in FrameRate::COMMON { + for f in -10_000..=10_000 { + let t = r.tick_of(f); + let back = r.frame_at(t); + assert_eq!(back, f, "Roundtrip failed for {} at frame {}", r.label(), f); + assert_eq!(r.snap(t), t, "Snap of exact boundary should be identity"); + assert_eq!(r.snap_nearest(t), t, "Snap nearest of exact boundary should be identity"); + } + } +} + +#[test] +fn time_range_invariants() { + let r1 = TimeRange::new(Tick(100), Tick(50)); + assert_eq!(r1.end(), Tick(150)); + assert!(r1.contains(Tick(100))); + assert!(r1.contains(Tick(149))); + assert!(!r1.contains(Tick(150))); + + let r2 = TimeRange::new(Tick(120), Tick(40)); + assert!(r1.overlaps(&r2)); + let inter = r1.intersect(&r2); + assert_eq!(inter, Some(TimeRange::new(Tick(120), Tick(30)))); + + let empty = TimeRange::new(Tick(100), Tick(0)); + assert!(empty.is_empty()); +} + +#[test] +fn tick_checked_and_saturating_arithmetic_contract() { + // 1. Division overflow: i64::MIN / -1 + let min_tick = Tick(i64::MIN); + assert_eq!(min_tick.checked_div(-1), None, "checked_div must return None on i64::MIN / -1"); + assert_eq!(min_tick.saturating_div(-1), Some(Tick(i64::MAX)), "saturating_div must saturate to Tick(i64::MAX)"); + assert_eq!(min_tick / -1, Tick(i64::MAX), "Operator Div must saturate i64::MIN / -1 to Tick(i64::MAX) without panicking"); + assert_eq!(Tick(i64::MIN + 1) / -1, Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN + 2) / -1, Tick(i64::MAX - 1)); + + // 2. Division by zero + let ten = Tick(10); + assert_eq!(ten.checked_div(0), None, "checked_div must return None on division by zero"); + assert_eq!(ten.saturating_div(0), None, "saturating_div must return None on division by zero"); + assert_eq!(ten / 0, Tick::ZERO, "Operator Div fallback returns Tick::ZERO on zero divisor"); + + // 3. Remainder overflow & zero divisor + assert_eq!(min_tick.checked_rem(Tick(-1)), None, "checked_rem must return None on i64::MIN % -1"); + assert_eq!(ten.checked_rem(Tick(0)), None, "checked_rem must return None on zero divisor"); + assert_eq!(min_tick % Tick(-1), Tick::ZERO, "Operator Rem on i64::MIN % -1 produces Tick::ZERO"); + assert_eq!(ten % Tick(0), Tick::ZERO, "Operator Rem on zero divisor produces Tick::ZERO"); + + // 4. Checked Add / Sub / Mul + assert_eq!(Tick(i64::MAX).checked_add(Tick(1)), None); + assert_eq!(Tick(i64::MIN).checked_sub(Tick(1)), None); + assert_eq!(Tick(i64::MAX).checked_mul(2), None); + assert_eq!(Tick(100).checked_add(Tick(50)), Some(Tick(150))); + assert_eq!(Tick(100).checked_sub(Tick(50)), Some(Tick(50))); + assert_eq!(Tick(100).checked_mul(5), Some(Tick(500))); + + // 5. Saturating Add / Sub / Mul + assert_eq!(Tick(i64::MAX).saturating_add(Tick(1)), Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN).saturating_sub(Tick(1)), Tick(i64::MIN)); + assert_eq!(Tick(i64::MAX).saturating_mul(2), Tick(i64::MAX)); +} + +#[test] +fn hostile_rates_and_timecodes_never_panic() { + let zero = FrameRate { num: 0, den: 0 }; + for r in [ + zero, + FrameRate { num: 0, den: 1 }, + FrameRate { num: 25, den: 0 }, + FrameRate { num: -30, den: 1 }, + FrameRate::from_f64(0.0), + FrameRate::from_f64(f64::NAN), + ] { + assert_eq!(r.tick_of(10), FrameRate::default().tick_of(10)); + assert_eq!(r.frame_at(Tick(TICKS_PER_SECOND)), FrameRate::default().frame_at(Tick(TICKS_PER_SECOND))); + assert!(r.frame_duration() > Tick::ZERO); + assert!(r.timecode_base() >= 1); + let _ = r.snap_nearest(Tick(12345)); + let _ = format_time(Tick(TICKS_PER_SECOND), r, true, TimeDisplay::Timecode, 48_000); + } + assert_eq!(Tick::from_rational(100, 1, 0), Tick::ZERO); + assert_eq!(Tick::from_units(100, 0), Tick::ZERO); + assert_eq!(Tick(5).to_rational_floor(0, 1), 0); + assert_eq!(Tick(5).to_rational_round(0, 1), 0); + for t in [i64::MIN, -1, 0, 1, i64::MAX] { + for (n, d) in [(i64::MIN, 1), (-1, 1000), (1, i64::MAX), (i64::MAX, i64::MIN), (1, 0)] { + let _ = Tick(t).to_rational_round(n, d); + } + } + assert_eq!(Tick(5).mul_ratio(1, 0), Tick::ZERO); + assert_eq!(Tick(5).clamp(Tick(10), Tick(0)), Tick(0)); + let _ = format_timecode_frames(i64::MIN, FrameRate::FPS_29_97, true); + for tc in ["99999999999:99:99:99", "9223372036854775807", "+9223372036854775807", "99999999999;59;59;29"] { + let _ = parse_timecode(tc, FrameRate::FPS_29_97, true, i64::MAX); + let _ = parse_timecode(tc, FrameRate::FPS_25, false, i64::MIN); + } +} + +#[test] +fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() { + let slow = FrameRate { num: 1, den: i64::MAX }; + assert_eq!(slow.frame_duration(), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN)); + assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating"); + assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO); + + let fast = FrameRate { num: i64::MAX, den: 1 }; + assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly"); + assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND)); + assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN); + assert_eq!(fast.timecode_base(), i64::MAX); + + for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] { + assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick"); + assert_eq!(fast.snap_nearest(Tick(t)), Tick(t)); + } + + let balanced = FrameRate { num: i64::MAX, den: i64::MAX }; + assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND)); + assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND)); + assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2); + assert_eq!(balanced.timecode_base(), 1); + assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 }); + assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 }); + assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); +} + +#[test] +fn scalar_time_conversions_saturate_at_storage_limits() { + assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN)); + assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO); + assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN); + assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX)); +} + +#[test] +fn smpte_drop_frame_exact_counts_and_negative_boundaries() { + let r = FrameRate::FPS_29_97; + assert!(r.supports_drop_frame()); + + // 10 minutes at 29.97: 10 * 60 * 30 - 9 * 2 = 17982 frames + let ten_min_frames = 17982; + let tc = format_timecode_frames(ten_min_frames, r, true); + assert_eq!(tc, "00;10;00;00"); + assert_eq!(parse_timecode(&tc, r, true, 0), Ok(ten_min_frames)); + + // 1 minute drop-frame boundary: frame 1800 is 00;01;00;02 + let one_min_frame = 1800; + let tc_1m = format_timecode_frames(one_min_frame, r, true); + assert_eq!(tc_1m, "00;01;00;02"); + assert_eq!(parse_timecode(&tc_1m, r, true, 0), Ok(one_min_frame)); + + // Negative drop-frame boundaries + let neg_1m = -1800; + let neg_tc = format_timecode_frames(neg_1m, r, true); + assert_eq!(neg_tc, "-00;01;00;02"); + assert_eq!(parse_timecode(&neg_tc, r, true, 0), Ok(neg_1m)); + + let neg_10m = -17982; + let neg_10m_tc = format_timecode_frames(neg_10m, r, true); + assert_eq!(neg_10m_tc, "-00;10;00;00"); + assert_eq!(parse_timecode(&neg_10m_tc, r, true, 0), Ok(neg_10m)); + + let neg_1h = -107892; + let neg_1h_tc = format_timecode_frames(neg_1h, r, true); + assert_eq!(neg_1h_tc, "-01;00;00;00"); + assert_eq!(parse_timecode(&neg_1h_tc, r, true, 0), Ok(neg_1h)); +} + +#[test] +fn effectcraft_ae_differential_formatting() { + let r30 = FrameRate::FPS_30; + let r_df = FrameRate::FPS_29_97; + + // EffectCraft / After Effects formats hours without zero-padding + assert_eq!(format_timecode_ae(75, r30, false), "0:00:02:15"); + assert_eq!(format_timecode_ae(75, r_df, true), "0;00;02;15"); + + // FilmCraft / Premiere formats hours with 2 zero-padded digits + assert_eq!(format_timecode_frames(75, r30, false), "00:00:02:15"); + assert_eq!(format_timecode_frames(75, r_df, true), "00;00;02;15"); + + // Negative frames in AE style + assert_eq!(format_timecode_ae(-75, r30, false), "-0:00:02:15"); + assert_eq!(format_timecode_ae(-1800, r_df, true), "-0;01;00;02"); + + // Multi-hour parity + assert_eq!(format_timecode_ae(3600 * 30 * 12, r30, false), "12:00:00:00"); + assert_eq!(format_timecode_frames(3600 * 30 * 12, r30, false), "12:00:00:00"); +} + +#[test] +fn feet_and_frames_film_standards() { + // 35mm: 16 frames per foot + assert_eq!(format_feet_frames(0, 16), "0000+00"); + assert_eq!(format_feet_frames(199, 16), "0012+07"); + assert_eq!(parse_feet_frames("12+07", 16, 0), Ok(199)); + assert_eq!(parse_feet_frames("0012+07", 16, 0), Ok(199)); + assert_eq!(format_feet_frames(-199, 16), "-0012+07"); + assert_eq!(parse_feet_frames("-0012+07", 16, 0), Ok(-199)); + + // 16mm: 40 frames per foot + assert_eq!(format_feet_frames(95, 40), "0002+15"); + assert_eq!(parse_feet_frames("2+15", 40, 0), Ok(95)); +} + +#[test] +fn exotic_frame_rate_durations() { + let r = FrameRate::from_f64(37.516); + assert_eq!(r.frame_duration().0, 6_770_871_094); + + // Standard rates must remain exact + assert_eq!(FrameRate::FPS_23_976.frame_duration().0, 10_594_584_000); + assert_eq!(FrameRate::FPS_24.frame_duration().0, 10_584_000_000); + assert_eq!(FrameRate::FPS_25.frame_duration().0, 10_160_640_000); + assert_eq!(FrameRate::FPS_29_97.frame_duration().0, 8_475_667_200); + assert_eq!(FrameRate::FPS_30.frame_duration().0, 8_467_200_000); + assert_eq!(FrameRate::FPS_60.frame_duration().0, 4_233_600_000); +} + +proptest! { + #[test] + fn prop_monotonic_frame_math( + frame in -100_000i64..100_000, + rate_idx in 0usize..FrameRate::COMMON.len(), + ) { + let r = FrameRate::COMMON[rate_idx]; + let t1 = r.tick_of(frame); + let t2 = r.tick_of(frame + 1); + prop_assert!(t1 < t2, "tick_of must be strictly increasing"); + prop_assert_eq!(r.frame_at(t1), frame); + prop_assert_eq!(r.frame_at(t2 - Tick(1)), frame); + } + + #[test] + fn prop_feet_frames_roundtrip( + frame in -50_000i64..50_000, + per_foot in 1i64..120, + ) { + let s = format_feet_frames(frame, per_foot); + let parsed = parse_feet_frames(&s, per_foot, 0); + prop_assert_eq!(parsed, Ok(frame)); + } + + #[test] + fn prop_drop_frame_bijection(f in 0i64..(24 * 107_892)) { + let r = FrameRate::FPS_29_97; + let (_, _h, m, s, fr) = frames_to_fields(f, r, true); + // dropped labels never appear + prop_assert!(!(s == 0 && fr < 2 && m % 10 != 0)); + let txt = format_timecode_frames(f, r, true); + prop_assert_eq!(parse_timecode(&txt, r, true, 0).unwrap(), f); + } + + #[test] + fn prop_ndf_parse_roundtrip(f in -500_000i64..500_000, ri in 0usize..11) { + let r = FrameRate::COMMON[ri]; + let txt = format_timecode_frames(f, r, false); + prop_assert_eq!(parse_timecode(&txt, r, false, 0).unwrap(), f); + } + + #[test] + fn prop_tick_div_monotonicity( + a in any::(), + b in any::(), + d in any::().prop_filter("non-zero", |&x| x != 0), + ) { + if a <= b { + if d > 0 { + prop_assert!((Tick(a) / d) <= (Tick(b) / d)); + } else { + prop_assert!((Tick(a) / d) >= (Tick(b) / d)); + } + } + } +} From 5416121e09a5f88763e027dac3aa33c56094b9a2 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Tue, 20 Oct 2026 17:14:16 -0700 Subject: [PATCH 002/118] Address review findings in craft-time (fixes #15) - Clarify Tick(i64::MIN) overflow documentation in division/remainder contracts. - Format negative audio sample timestamps with absolute components and leading minus sign. - Parse 4-field negative timecodes as absolute positions rather than relative offsets. - Reject signed values inside multi-field timecode components. --- crates/time/src/lib.rs | 96 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 002eccb38..a3fbfc355 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -119,12 +119,12 @@ impl Tick { self.0.checked_mul(rhs).map(Tick) } - /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick::MIN / -1`). + /// Checked division. Returns `None` if `rhs == 0` or if overflow occurred (`Tick(i64::MIN) / -1`). pub fn checked_div(self, rhs: i64) -> Option { self.0.checked_div(rhs).map(Tick) } - /// Checked remainder. Returns `None` if `rhs.0 == 0` or if overflow occurred (`Tick::MIN % Tick(-1)`). + /// Checked remainder. Returns `None` if `rhs.0 == 0` or if overflow occurred (`Tick(i64::MIN) % Tick(-1)`). pub fn checked_rem(self, rhs: Tick) -> Option { self.0.checked_rem(rhs.0).map(Tick) } @@ -144,7 +144,7 @@ impl Tick { Tick(self.0.saturating_mul(rhs)) } - /// Saturating division. Returns `None` if `rhs == 0`, and saturates on overflow (`Tick::MIN / -1 -> Tick(i64::MAX)`). + /// Saturating division. Returns `None` if `rhs == 0`, and saturates on overflow (`Tick(i64::MIN) / -1 -> Tick(i64::MAX)`). pub fn saturating_div(self, rhs: i64) -> Option { if rhs == 0 { None } else { Some(Tick(self.0.saturating_div(rhs))) } } @@ -193,7 +193,7 @@ impl Mul for Tick { impl Div for Tick { type Output = Tick; fn div(self, rhs: i64) -> Tick { - // Operator division policy: Div saturates on overflow (e.g. Tick::MIN / -1 -> Tick(i64::MAX)) + // Operator division policy: Div saturates on overflow (e.g. Tick(i64::MIN) / -1 -> Tick(i64::MAX)) // using self.0.saturating_div(rhs) to preserve monotonicity across the full range. // Division by zero yields Tick::ZERO. For fallible division that detects zero divisors, // use Tick::checked_div(). @@ -205,7 +205,7 @@ impl Rem for Tick { type Output = Tick; fn rem(self, rhs: Tick) -> Tick { // Operator remainder policy: Rem produces exact remainder where defined, - // and Tick::ZERO for zero divisor or Tick::MIN % Tick(-1) (which is mathematically 0). + // and Tick::ZERO for zero divisor or Tick(i64::MIN) % Tick(-1) (which is mathematically 0). // For fallible remainder, use Tick::checked_rem(). if rhs.0 == 0 || (self.0 == i64::MIN && rhs.0 == -1) { Tick::ZERO } else { Tick(self.0 % rhs.0) } } @@ -512,9 +512,15 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) } TimeDisplay::AudioSamples => { - let secs = t.0.div_euclid(TICKS_PER_SECOND); - let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate); - format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60) + let neg = t.0 < 0; + let abs_ticks = t.0.unsigned_abs(); + let total_secs = abs_ticks / (TICKS_PER_SECOND as u64); + let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64); + let rem_samples = (rem_ticks as u128 * sample_rate as u128) / (TICKS_PER_SECOND as u128); + let h = total_secs / 3600; + let m = (total_secs / 60) % 60; + let s = total_secs % 60; + format!("{}{:02}:{:02}:{:02}:{:05}", if neg { "-" } else { "" }, h, m, s, rem_samples) } TimeDisplay::Seconds => format!("{:.3}", t.seconds()), } @@ -555,9 +561,9 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i if s.is_empty() { return Err(ParseError("empty timecode".into())); } - let (rel, body) = match s.as_bytes()[0] { - b'+' => (Some(1), &s[1..]), - b'-' => (Some(-1), &s[1..]), + let (sign_char, body) = match s.as_bytes()[0] { + b'+' => (Some('+'), &s[1..]), + b'-' => (Some('-'), &s[1..]), _ => (None, s), }; let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); @@ -566,7 +572,7 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { return Err(ParseError(format!("not a timecode: `{input}`"))); } - if rel.is_some() { + if sign_char.is_some() { vec![digits.parse().map_err(|_| ParseError("number too large".into()))?] } else { let mut v = Vec::new(); @@ -583,7 +589,16 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i } else { parts .iter() - .map(|p| if p.is_empty() { Ok(0) } else { p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) }) + .map(|p| { + if p.is_empty() { + Ok(0) + } else { + if !p.bytes().all(|b| b.is_ascii_digit()) { + return Err(ParseError(format!("bad field `{p}`"))); + } + p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) + } + }) .collect::>()? }; if nums.len() > 4 { @@ -594,17 +609,23 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i f4[off..].copy_from_slice(&nums); let [h, m, sec, fr] = f4; let base = rate.timecode_base(); - let frames = if nums.len() == 1 && rel.is_some() { + let frames = if nums.len() == 1 && sign_char.is_some() { fr } else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base { fields_to_frames(h, m, sec, fr, rate, true) } else { h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) }; - Ok(match rel { - Some(sign) => current.saturating_add(frames.saturating_mul(sign)), - None => frames, - }) + + match sign_char { + Some('+') => Ok(current.saturating_add(frames)), + Some('-') => { + // A full 4-field timecode with leading '-' represents an absolute negative timecode (e.g. preroll). + // Single-number or partial relative offsets (e.g. -15 or -1.00) offset from current. + if nums.len() == 4 { Ok(-frames) } else { Ok(current.saturating_sub(frames)) } + } + None | Some(_) => Ok(frames), + } } /// Parse a Feet + Frames field into a frame count. @@ -697,6 +718,45 @@ mod tests { } } + #[test] + fn test_audio_samples_formatting_negative_ticks() { + let r = FrameRate::FPS_24; + let neg_1s = Tick(-TICKS_PER_SECOND); + assert_eq!(format_time(neg_1s, r, false, TimeDisplay::AudioSamples, 48_000), "-00:00:01:00000"); + + let pos_1s = Tick(TICKS_PER_SECOND); + assert_eq!(format_time(pos_1s, r, false, TimeDisplay::AudioSamples, 48_000), "00:00:01:00000"); + + let neg_complex = Tick(-3661 * TICKS_PER_SECOND); + assert_eq!(format_time(neg_complex, r, false, TimeDisplay::AudioSamples, 48_000), "-01:01:01:00000"); + + let zero = Tick::ZERO; + assert_eq!(format_time(zero, r, false, TimeDisplay::AudioSamples, 48_000), "00:00:00:00000"); + } + + #[test] + fn test_parse_timecode_negative_absolute_independent_of_current() { + let r = FrameRate::FPS_29_97; + // Negative full 4-field timecode must parse as absolute negative frames, not offset from current + assert_eq!(parse_timecode("-00;01;00;02", r, true, 5000), Ok(-1800)); + assert_eq!(parse_timecode("-00;01;00;02", r, true, 0), Ok(-1800)); + + let r24 = FrameRate::FPS_24; + assert_eq!(parse_timecode("-01:00:00:00", r24, false, 9999), Ok(-86400)); + + // Single-number relative entries must offset from current + assert_eq!(parse_timecode("-15", r24, false, 5000), Ok(4985)); + assert_eq!(parse_timecode("+15", r24, false, 5000), Ok(5015)); + } + + #[test] + fn test_parse_timecode_rejects_signed_inner_fields() { + let r = FrameRate::FPS_24; + assert!(parse_timecode("00:-30:00:00", r, false, 0).is_err()); + assert!(parse_timecode("01:+5:00:00", r, false, 0).is_err()); + assert!(parse_timecode("00:00:-01:00", r, false, 0).is_err()); + } + #[test] fn test_feet_frames_roundtrip() { for pf in [16, 40] { From ae0f0488dc7a97653a26efe69991a4b2588463ac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 03:44:51 +0000 Subject: [PATCH 003/118] craft-time: preserve SoundCraft time API and serialization --- Cargo.lock | 2 + crates/time/Cargo.toml | 2 + crates/time/src/lib.rs | 4 + crates/time/src/soundcraft/format.rs | 241 ++++++++ crates/time/src/soundcraft/grid.rs | 809 +++++++++++++++++++++++++ crates/time/src/soundcraft/mod.rs | 155 +++++ crates/time/src/soundcraft/tempo.rs | 622 +++++++++++++++++++ crates/time/src/soundcraft/timecode.rs | 309 ++++++++++ 8 files changed, 2144 insertions(+) create mode 100644 crates/time/src/soundcraft/format.rs create mode 100644 crates/time/src/soundcraft/grid.rs create mode 100644 crates/time/src/soundcraft/mod.rs create mode 100644 crates/time/src/soundcraft/tempo.rs create mode 100644 crates/time/src/soundcraft/timecode.rs diff --git a/Cargo.lock b/Cargo.lock index 89478f3cc..4136a81de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,6 +717,8 @@ version = "0.1.0" dependencies = [ "proptest", "serde", + "serde_json", + "thiserror 2.0.21", ] [[package]] diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml index 0f63cb228..5ef614bdc 100644 --- a/crates/time/Cargo.toml +++ b/crates/time/Cargo.toml @@ -12,6 +12,8 @@ workspace = true [dependencies] serde = { workspace = true } +thiserror.workspace = true [dev-dependencies] +serde_json = { workspace = true } proptest = "1.4" diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index a3fbfc355..0d3703060 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -15,6 +15,10 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::ops::{Add, AddAssign, Div, Mul, Neg, Rem, Sub, SubAssign}; +/// SoundCraft's sample-index, tempo-map, grid, and display-time API, retained verbatim for +/// session-format compatibility while sharing the broader Craft time primitives above. +pub mod soundcraft; + /// Clock ticks per second: 254 016 000 000. pub const TICKS_PER_SECOND: i64 = 254_016_000_000; diff --git a/crates/time/src/soundcraft/format.rs b/crates/time/src/soundcraft/format.rs new file mode 100644 index 000000000..11b588c3f --- /dev/null +++ b/crates/time/src/soundcraft/format.rs @@ -0,0 +1,241 @@ +//! Formatting and parsing positions in the five timebases. + +use super::{BarBeat, FrameRate, SampleRate, Samples, TempoMap, TimeError, Timecode, timecode::feet_frames}; + +/// The counters' and rulers' timebases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TimeFormat { + BarsBeats, + #[default] + MinSecs, + Timecode, + FeetFrames, + Samples, +} + +impl TimeFormat { + pub const ALL: [TimeFormat; 5] = [TimeFormat::BarsBeats, TimeFormat::MinSecs, TimeFormat::Timecode, TimeFormat::FeetFrames, TimeFormat::Samples]; + + pub fn label(self) -> &'static str { + match self { + TimeFormat::BarsBeats => "Bars|Beats", + TimeFormat::MinSecs => "Min:Secs", + TimeFormat::Timecode => "Timecode", + TimeFormat::FeetFrames => "Feet+Frames", + TimeFormat::Samples => "Samples", + } + } + + pub fn id(self) -> &'static str { + match self { + TimeFormat::BarsBeats => "bars_beats", + TimeFormat::MinSecs => "min_secs", + TimeFormat::Timecode => "timecode", + TimeFormat::FeetFrames => "feet_frames", + TimeFormat::Samples => "samples", + } + } + + pub fn from_id(id: &str) -> Option { + TimeFormat::ALL.into_iter().find(|f| f.id() == id || f.label().eq_ignore_ascii_case(id)) + } +} + +fn min_secs(s: Samples, sr: SampleRate) -> String { + let neg = s < 0; + // Integer maths: a float `seconds * 1000` lands just below exact millisecond positions. + let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX); + let mins = ms_total / 60_000; + let secs = (ms_total / 1000) % 60; + let ms = ms_total % 1000; + format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" }) +} + +/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the +/// feet+frames reading of sample 0 in frames. +pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String { + match fmt { + TimeFormat::Samples => s.to_string(), + TimeFormat::MinSecs => min_secs(s, sr), + TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate), + TimeFormat::FeetFrames => { + let (ft, fr) = feet_frames(s, rate, sr, ff_start); + format!("{ft}+{fr:02}") + } + TimeFormat::BarsBeats => { + let bb = map.bar_beat_at(s, sr); + format!("{}| {}| {:03}", bb.bar, bb.beat, bb.tick) + } + } +} + +/// Format a length starting at `start` (Bars|Beats lengths count from 0|0|000). +pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> String { + match fmt { + TimeFormat::BarsBeats => { + let t0 = map.samples_to_ticks(start, sr); + let t1 = map.samples_to_ticks(start.saturating_add(len), sr); + let m = map.meter_at_tick(t0); + let d = (t1 - t0).max(0); + let bars = d / m.ticks_per_bar().max(1); + let rem = d % m.ticks_per_bar().max(1); + format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) + } + TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate), + other => format_position(len, other, sr, map, rate, 0, 0), + } +} + +/// Parse a position typed into a counter or dialog. +pub fn parse_position( + text: &str, + fmt: TimeFormat, + sr: SampleRate, + map: &TempoMap, + rate: FrameRate, + tc_start: Samples, + ff_start: i64, +) -> Result { + let t = text.trim(); + let err = || TimeError::Parse(t.to_string(), fmt.label()); + match fmt { + TimeFormat::Samples => t.replace([',', '_'], "").parse::().map_err(|_| err()), + TimeFormat::MinSecs => { + let (neg, body) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t), + }; + let (m, s) = match body.split_once(':') { + Some((m, s)) => (m.trim().parse::().map_err(|_| err())?, s.trim().parse::().map_err(|_| err())?), + None => (0.0, body.parse::().map_err(|_| err())?), + }; + if !m.is_finite() || !s.is_finite() || m < 0.0 || s < 0.0 { + return Err(err()); + } + let v = sr.samples(m * 60.0 + s); + Ok(if neg { -v } else { v }) + } + TimeFormat::Timecode => { + let tc = Timecode::parse(t)?; + if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) { + return Err(err()); + } + if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 { + let dropped = if rate.nominal() >= 60 { 4 } else { 2 }; + if tc.frames < dropped { + return Err(err()); + } + } + Ok(tc.to_samples(rate, sr).saturating_sub(tc_start)) + } + TimeFormat::FeetFrames => { + let (ft, fr) = t.split_once('+').ok_or_else(err)?; + let ft: i64 = ft.trim().parse().map_err(|_| err())?; + let fr: i64 = fr.trim().parse().map_err(|_| err())?; + Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr)) + } + TimeFormat::BarsBeats => { + let parts: Vec = + t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::).collect::>().map_err(|_| err())?; + let bb = BarBeat { + bar: *parts.first().ok_or_else(err)?, + beat: parts.get(1).copied().unwrap_or(1).max(1), + tick: parts.get(2).copied().unwrap_or(0).max(0), + }; + Ok(map.samples_at_bar_beat(bb, sr)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> (SampleRate, TempoMap, FrameRate) { + (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30) + } + + #[test] + fn huge_typed_positions_parse_without_overflowing() { + let (sr, map, rate) = ctx(); + for (text, fmt) in [ + ("999999999999999999:0", TimeFormat::Timecode), + ("100000000000000:00:00:00", TimeFormat::Timecode), + ("-9223372036854775808", TimeFormat::BarsBeats), + ] { + let _ = parse_position(text, fmt, sr, &map, rate, 0, 0); + } + } + + #[test] + fn formats_like_pro_tools_counters() { + let (sr, map, rate) = ctx(); + assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000"); + assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500"); + assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000"); + assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00"); + assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345"); + } + + #[test] + fn minimum_bar_parses_without_overflow() { + let (sr, map, rate) = ctx(); + let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0); + assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0)); + assert_eq!(p("2|3|000").ok(), Some(144_000)); + assert_eq!(p("0|1|000").ok(), Some(-96_000)); + } + + #[test] + fn parse_round_trips() { + let (sr, map, rate) = ctx(); + for fmt in TimeFormat::ALL { + for s in [0i64, 48_000, 96_000 * 3, 1_440_000] { + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); + assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}"); + } + } + } + + #[test] + fn parse_round_trips_timecode_negative() { + let (sr, map, _) = ctx(); + let rate = FrameRate::Fps25; + for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] { + for s in [-48_000i64, -96_000 * 3, -1_440_000] { + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); + assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}"); + } + } + } + + #[test] + fn parse_rejects_garbage() { + let (sr, map, rate) = ctx(); + assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err()); + } + + #[test] + fn drop_frame_skipped_labels_are_rejected() { + let (sr, map, _) = ctx(); + for (rate, skipped) in [ + (FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]), + (FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]), + ] { + for text in skipped { + assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}"); + } + assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok()); + } + } + + #[test] + fn bar_lengths() { + let (sr, map, rate) = ctx(); + assert_eq!(format_length(0, 96_000 + 24_000, TimeFormat::BarsBeats, sr, &map, rate), "1| 1| 000"); + } +} diff --git a/crates/time/src/soundcraft/grid.rs b/crates/time/src/soundcraft/grid.rs new file mode 100644 index 000000000..5f32ca6d8 --- /dev/null +++ b/crates/time/src/soundcraft/grid.rs @@ -0,0 +1,809 @@ +//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping. + +use super::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, Timecode, to_samples}; + +/// Note values for Bars|Beats grids. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum NoteValue { + Bar, + Whole, + Half, + Quarter, + Eighth, + Sixteenth, + ThirtySecond, + SixtyFourth, +} + +impl NoteValue { + /// Every musical increment in menu order, from largest to smallest. + pub const ALL: [NoteValue; 8] = [ + NoteValue::Bar, + NoteValue::Whole, + NoteValue::Half, + NoteValue::Quarter, + NoteValue::Eighth, + NoteValue::Sixteenth, + NoteValue::ThirtySecond, + NoteValue::SixtyFourth, + ]; + /// Returns the nominal tick length; bars are adjusted for the active meter when used. + pub fn ticks(self) -> i64 { + match self { + NoteValue::Bar => TICKS_PER_QUARTER * 4, + NoteValue::Whole => TICKS_PER_QUARTER * 4, + NoteValue::Half => TICKS_PER_QUARTER * 2, + NoteValue::Quarter => TICKS_PER_QUARTER, + NoteValue::Eighth => TICKS_PER_QUARTER / 2, + NoteValue::Sixteenth => TICKS_PER_QUARTER / 4, + NoteValue::ThirtySecond => TICKS_PER_QUARTER / 8, + NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16, + } + } + /// Returns the menu label for this musical increment. + pub fn label(self) -> &'static str { + match self { + NoteValue::Bar => "1 bar", + NoteValue::Whole => "Whole note", + NoteValue::Half => "1/2 note", + NoteValue::Quarter => "1/4 note", + NoteValue::Eighth => "1/8 note", + NoteValue::Sixteenth => "1/16 note", + NoteValue::ThirtySecond => "1/32 note", + NoteValue::SixtyFourth => "1/64 note", + } + } +} + +/// The time scale used by a Grid or Nudge value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum GridScale { + BarsBeats, + MinSecs, + Timecode, + FeetFrames, + Samples, + ClipsMarkers, +} + +impl GridScale { + /// Numeric time scales shared by Grid and Nudge. + pub const TIME: [GridScale; 5] = [GridScale::BarsBeats, GridScale::MinSecs, GridScale::Timecode, GridScale::FeetFrames, GridScale::Samples]; + + /// Returns the user-facing scale label. + pub fn label(self) -> &'static str { + match self { + GridScale::BarsBeats => "Bars|Beats", + GridScale::MinSecs => "Min:Secs", + GridScale::Timecode => "Timecode", + GridScale::FeetFrames => "Feet+Frames", + GridScale::Samples => "Samples", + GridScale::ClipsMarkers => "Clips/Markers", + } + } + + /// Returns the stable command and session identifier. + pub fn id(self) -> &'static str { + match self { + GridScale::BarsBeats => "bars_beats", + GridScale::MinSecs => "min_secs", + GridScale::Timecode => "timecode", + GridScale::FeetFrames => "feet_frames", + GridScale::Samples => "samples", + GridScale::ClipsMarkers => "clips_markers", + } + } + + /// Parses a stable identifier or user-facing label. + pub fn from_id(id: &str) -> Option { + Self::TIME.into_iter().chain([GridScale::ClipsMarkers]).find(|scale| scale.id() == id || scale.label().eq_ignore_ascii_case(id)) + } +} + +/// Timecode Grid/Nudge increments, ordered independently of the session frame rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum TimecodeValue { + Second, + /// Five physical frames, independent of skipped drop-frame labels. + FiveFrames, + Frame, + HalfFrame, + QuarterFrame, + SubFrame, +} + +impl TimecodeValue { + /// Every Timecode increment in menu order, from largest to smallest. + pub const ALL: [TimecodeValue; 6] = [ + TimecodeValue::Second, + TimecodeValue::FiveFrames, + TimecodeValue::Frame, + TimecodeValue::HalfFrame, + TimecodeValue::QuarterFrame, + TimecodeValue::SubFrame, + ]; + + /// Returns the user-facing increment label. + pub fn label(self) -> &'static str { + match self { + TimecodeValue::Second => "1 second", + TimecodeValue::FiveFrames => "5 frames", + TimecodeValue::Frame => "1 frame", + TimecodeValue::HalfFrame => "1/2 frame", + TimecodeValue::QuarterFrame => "1/4 frame", + TimecodeValue::SubFrame => "1 subframe", + } + } + + /// Returns the stable command and session identifier. + pub fn id(self) -> &'static str { + match self { + TimecodeValue::Second => "second", + TimecodeValue::FiveFrames => "5_frames", + TimecodeValue::Frame => "frame", + TimecodeValue::HalfFrame => "half_frame", + TimecodeValue::QuarterFrame => "quarter_frame", + TimecodeValue::SubFrame => "subframe", + } + } + + /// Parses a stable identifier or user-facing label. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id)) + } + + /// Returns the increment length in frames at the session rate. + fn frames(self, rate: FrameRate) -> f64 { + match self { + TimecodeValue::Second => rate.nominal() as f64, + TimecodeValue::FiveFrames => 5.0, + TimecodeValue::Frame => 1.0, + TimecodeValue::HalfFrame => 0.5, + TimecodeValue::QuarterFrame => 0.25, + TimecodeValue::SubFrame => 0.01, + } + } +} + +/// Feet+Frames Grid/Nudge increments in physical frames, independent of drop-frame label skips. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum FeetFramesValue { + Foot, + Frame, + QuarterFrame, + SubFrame, +} + +impl FeetFramesValue { + /// Every Feet+Frames increment in menu order, from largest to smallest. + pub const ALL: [FeetFramesValue; 4] = [FeetFramesValue::Foot, FeetFramesValue::Frame, FeetFramesValue::QuarterFrame, FeetFramesValue::SubFrame]; + + /// Returns the user-facing increment label. + pub fn label(self) -> &'static str { + match self { + FeetFramesValue::Foot => "1 foot", + FeetFramesValue::Frame => "1 frame", + FeetFramesValue::QuarterFrame => "1/4 frame", + FeetFramesValue::SubFrame => "1 subframe", + } + } + + /// Returns the stable command and session identifier. + pub fn id(self) -> &'static str { + match self { + FeetFramesValue::Foot => "foot", + FeetFramesValue::Frame => "frame", + FeetFramesValue::QuarterFrame => "quarter_frame", + FeetFramesValue::SubFrame => "subframe", + } + } + + /// Parses a stable identifier or user-facing label. + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|value| value.id() == id || value.label().eq_ignore_ascii_case(id)) + } + + /// Returns the increment length in frames. + fn frames(self) -> f64 { + let (num, den) = self.frame_ratio(); + num as f64 / den as f64 + } + + /// Exact numerator and denominator of the increment in physical frames. + fn frame_ratio(self) -> (i128, i128) { + match self { + Self::Foot => (16, 1), + Self::Frame => (1, 1), + Self::QuarterFrame => (1, 4), + Self::SubFrame => (1, 100), + } + } + + /// Apply the counter origin in frames before rounding once to the nearest sample. + fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + let (num, den) = self.frame_ratio(); + let (rate_num, rate_den) = rate.rational(); + let frames = index * num - i128::from(origin) * den; + let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den); + let divisor = den * i128::from(rate_num); + let magnitude = (samples.abs() + divisor / 2) / divisor; + let rounded = if samples < 0 { -magnitude } else { magnitude }; + i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }) + } + + /// Find a line index using exact frame arithmetic, allowing for whole-sample rounding. + fn line_index(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> i128 { + let (num, den) = self.frame_ratio(); + let (rate_num, rate_den) = rate.rational(); + let scale = i128::from(sr.hz().max(1)) * i128::from(rate_den); + let frames = i128::from(position) * i128::from(rate_num) + i128::from(origin) * scale; + let mut index = (frames * den).div_euclid(scale * num); + if self.sample_line(index + 1, sr, rate, origin) <= position { + index += 1; + } + index + } + + /// The Feet+Frames line at or before a sample, relative to the counter's frame origin. + pub fn line_at_or_before(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + self.sample_line(self.line_index(position, sr, rate, origin), sr, rate, origin) + } + + /// The next distinct sample line; subframe increments may share a sample at low sample rates. + fn next_line(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + self.sample_line(self.line_index(position, sr, rate, origin) + 1, sr, rate, origin).max(position.saturating_add(1)) + } + + /// Snap against frame-origin lines, choosing the earlier line when distances are equal. + pub fn snap(self, position: Samples, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { + let lo = self.line_at_or_before(position, sr, rate, origin); + let hi = self.next_line(lo, sr, rate, origin); + if i128::from(position) - i128::from(lo) <= i128::from(hi) - i128::from(position) { lo } else { hi } + } + + /// Distinct Feet+Frames sample lines within `[start, end)`, capped at `max`. + pub fn lines(self, start: Samples, end: Samples, sr: SampleRate, rate: FrameRate, origin: i64, max: usize) -> Vec { + let mut out = Vec::new(); + let mut line = self.line_at_or_before(start, sr, rate, origin); + while line < end && out.len() < max { + if line >= start { + out.push(line); + } + let next = self.next_line(line, sr, rate, origin); + if next <= line { + break; + } + line = next; + } + out + } +} + +/// A grid or nudge step. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum GridValue { + /// Musical: note value, optionally dotted or triplet. + Note { value: NoteValue, dotted: bool, triplet: bool }, + /// Absolute seconds (Min:Secs grids: 0.001 … 1 s). + Seconds(f64), + /// Frames at the session timecode rate. + /// Kept for sessions and commands written before named timecode increments were added. + Frames(i64), + /// A named Timecode increment, including fractional frames and one second. + Timecode(TimecodeValue), + /// A Feet+Frames increment (one foot is 16 frames). + FeetFrames(FeetFramesValue), + /// Raw samples. + Samples(i64), +} + +impl Default for GridValue { + /// Starts on a straight sixteenth-note Bars|Beats grid. + fn default() -> Self { + GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false } + } +} + +impl GridValue { + /// Returns the user-facing label for this increment. + pub fn label(&self) -> String { + match self { + GridValue::Note { value, dotted, triplet } => { + let mut s = value.label().to_string(); + if *dotted { + s.push_str(" dotted"); + } + if *triplet { + s.push_str(" triplet"); + } + s + } + GridValue::Seconds(s) => { + if *s >= 1.0 { + format!("{s} second{}", if *s == 1.0 { "" } else { "s" }) + } else { + format!("{} ms", (s * 1000.0).round() as i64) + } + } + GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }), + GridValue::Timecode(value) => value.label().to_string(), + GridValue::FeetFrames(value) => value.label().to_string(), + GridValue::Samples(n) => format!("{n} sample{}", if *n == 1 { "" } else { "s" }), + } + } + + /// Returns the time scale to which this increment belongs. + pub fn scale(self) -> GridScale { + match self { + GridValue::Note { .. } => GridScale::BarsBeats, + GridValue::Seconds(_) => GridScale::MinSecs, + GridValue::Frames(_) | GridValue::Timecode(_) => GridScale::Timecode, + GridValue::FeetFrames(_) => GridScale::FeetFrames, + GridValue::Samples(_) => GridScale::Samples, + } + } + + /// Returns the default increment for a newly selected time scale. + pub fn for_scale(scale: GridScale) -> GridValue { + match scale { + GridScale::BarsBeats => GridValue::default(), + GridScale::MinSecs => GridValue::Seconds(1.0), + GridScale::Timecode => GridValue::Timecode(TimecodeValue::Frame), + GridScale::FeetFrames => GridValue::FeetFrames(FeetFramesValue::Frame), + GridScale::Samples => GridValue::Samples(100), + GridScale::ClipsMarkers => GridValue::default(), + } + } + + /// The next larger or smaller menu value of the same kind, or `self` at either end. Note values keep + /// their dot or triplet modifier. + pub fn stepped(self, larger: bool) -> GridValue { + /// Finds the next value in a numeric increment ladder without wrapping. + fn next(ladder: &[T], v: T, larger: bool) -> Option { + if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) } + } + match self { + GridValue::Note { value, dotted, triplet } => { + let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0); + let j = if larger { i.checked_sub(1) } else { i.checked_add(1) }; + j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet }) + } + GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 0.5, 1.0], s, larger).map_or(self, GridValue::Seconds), + GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames), + GridValue::Timecode(value) => step_enum(&TimecodeValue::ALL, value, larger).map_or(self, GridValue::Timecode), + GridValue::FeetFrames(value) => step_enum(&FeetFramesValue::ALL, value, larger).map_or(self, GridValue::FeetFrames), + GridValue::Samples(n) => next(&[1, 2, 10, 100, 1_000, 10_000], n, larger).map_or(self, GridValue::Samples), + } + } + + fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 { + let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() }; + let mut t = base; + if dotted { + t = t * 3 / 2; + } + if triplet { + t = t * 2 / 3; + } + t.max(1) + } + + /// The step in samples at position `at` (musical steps depend on tempo). + pub fn step_samples(&self, at: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + match *self { + GridValue::Note { value, dotted, triplet } => { + let t0 = map.samples_to_ticks(at, sr); + let dt = Self::note_ticks(value, dotted, triplet, map, t0); + (map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1) + } + GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1), + GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1), + GridValue::Timecode(value) => to_samples(rate.samples_per_frame(sr) * value.frames(rate)).max(1), + GridValue::FeetFrames(value) => to_samples(rate.samples_per_frame(sr) * value.frames()).max(1), + GridValue::Samples(n) => n.max(1), + } + } + + /// Nearest grid line to `pos`. + pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + if let Self::FeetFrames(value) = *self { + return value.snap(pos, sr, rate, 0); + } + let lo = self.line_at_or_before(pos, sr, map, rate); + let hi = self.next_line(lo, sr, map, rate); + if (pos - lo) <= (hi - pos) { lo } else { hi } + } + + /// The grid line at or before `pos`. + pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + match *self { + Self::FeetFrames(value) => value.line_at_or_before(pos, sr, rate, 0), + GridValue::Note { value, dotted, triplet } => { + // Float ticks can land a hair either side of a line's own tick. + let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64; + if map.tick_to_samples(t, sr) > pos { + t = t.saturating_sub(1); + } else if map.tick_to_samples(t.saturating_add(1), sr) <= pos { + t = t.saturating_add(1); + } + // Sub-bar values restart at each bar so odd meters stay aligned. A whole note is + // always four quarter notes and therefore keeps a continuous tick origin. + let bb = map.bar_beat_at_tick(t); + let bar_tick = map.bar_start_tick(bb.bar); + let origin = if value == NoteValue::Whole { 0 } else { bar_tick }; + let step = Self::note_ticks(value, dotted, triplet, map, origin); + let k = (t - origin).div_euclid(step); + map.tick_to_samples(origin + k * step, sr) + } + _ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate), + } + } + + /// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step + /// rounded once, so rounding never accumulates from line to line. + fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples { + if let Self::FeetFrames(value) = *self { + return value.sample_line(i128::from(k), sr, rate, 0); + } + if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() { + return rate.samples_at_frame(Self::drop_second_frame(k, rate), sr); + } + if let Some(frames) = self.frame_step(rate) { + let whole = frames.round(); + if frames == whole { + return rate.samples_at_frame(k.saturating_mul(whole as i64), sr); + } + let step = (rate.samples_per_frame(sr) * frames).max(1.0); + return to_samples(k as f64 * step); + } + match *self { + GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)), + GridValue::Samples(n) => k.saturating_mul(n.max(1)), + GridValue::Note { .. } => 0, + GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => 0, + } + } + + /// Index of the absolute grid line at or before `pos`. + fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 { + if matches!(self, GridValue::Timecode(TimecodeValue::Second)) && rate.is_drop() { + let tc = Timecode::from_frames(rate.frames_at(pos, sr), rate); + let magnitude = tc.hours.saturating_mul(3600).saturating_add(tc.minutes.saturating_mul(60)).saturating_add(tc.seconds); + let mut k = if tc.negative { magnitude.saturating_neg() } else { magnitude }; + while self.abs_line(k, sr, rate) > pos { + let previous = k.saturating_sub(1); + if previous == k { + break; + } + k = previous; + } + while self.abs_line(k.saturating_add(1), sr, rate) <= pos { + let next = k.saturating_add(1); + if next == k { + break; + } + k = next; + } + return k; + } + let mut k = if let Some(frames) = self.frame_step(rate) { + let step = (rate.samples_per_frame(sr) * frames).max(1.0); + (pos as f64 / step).floor() as i64 + } else { + match *self { + GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64, + GridValue::Samples(n) => return pos.div_euclid(n.max(1)), + GridValue::Note { .. } => return 0, + GridValue::Frames(_) | GridValue::Timecode(_) | GridValue::FeetFrames(_) => return 0, + } + }; + // Lines are rounded to whole samples, so the floored index can be one off either way. + if self.abs_line(k, sr, rate) > pos { + k = k.saturating_sub(1); + } else if self.abs_line(k.saturating_add(1), sr, rate) <= pos { + k = k.saturating_add(1); + } + k + } + + /// The exact (fractional) Seconds step in samples, at least one sample. + fn seconds_step(s: f64, sr: SampleRate) -> f64 { + (s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0) + } + + /// Maps a signed whole-second timecode label to its physical frame at a drop-frame rate. + fn drop_second_frame(index: i64, rate: FrameRate) -> i64 { + let seconds = index.unsigned_abs(); + let total_minutes = seconds / 60; + let frames = if seconds.is_multiple_of(60) && !total_minutes.is_multiple_of(10) { if rate.nominal() >= 60 { 4 } else { 2 } } else { 0 }; + Timecode { + negative: index < 0, + hours: i64::try_from(seconds / 3600).unwrap_or(i64::MAX), + minutes: i64::try_from((seconds / 60) % 60).unwrap_or(0), + seconds: i64::try_from(seconds % 60).unwrap_or(0), + frames, + } + .to_frames(rate) + } + + /// A frame-based increment as a possibly fractional number of frames. + fn frame_step(&self, rate: FrameRate) -> Option { + match *self { + GridValue::Frames(frames) => Some(frames.clamp(1, 1_000_000) as f64), + GridValue::Timecode(value) => Some(value.frames(rate)), + GridValue::FeetFrames(value) => Some(value.frames()), + _ => None, + } + } + + /// The first grid line strictly after `line`. + pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { + if let Self::FeetFrames(value) = *self { + return value.next_line(line, sr, rate, 0); + } + if !matches!(self, GridValue::Note { .. }) { + let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate); + return if next > line { next } else { line.saturating_add(1) }; + } + let step = self.step_samples(line, sr, map, rate); + let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate); + if candidate > line { candidate } else { line.saturating_add(step) } + } + + /// All grid lines within `[start, end)`, capped at `max` lines. + pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec { + if let Self::FeetFrames(value) = *self { + return value.lines(start, end, sr, rate, 0, max); + } + let mut out = Vec::new(); + let mut p = self.line_at_or_before(start, sr, map, rate); + while p < end && out.len() < max { + if p >= start { + out.push(p); + } + let n = self.next_line(p, sr, map, rate); + if n <= p { + break; + } + p = n; + } + out + } +} + +/// Moves one position through an ordered increment menu without wrapping. +fn step_enum(values: &[T], value: T, larger: bool) -> Option { + let index = values.iter().position(|candidate| *candidate == value)?; + let next = if larger { index.checked_sub(1) } else { index.checked_add(1) }?; + values.get(next).copied() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Frame origins are subtracted before rounding whole frames, quarter frames and subframes. + #[test] + fn feet_frames_origins_round_only_after_applying_the_frame_offset() { + let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97); + for at in [49_650, 100_901, 177_778] { + assert_eq!(FeetFramesValue::Foot.line_at_or_before(at, sr, rate, 1), at); + assert_eq!(FeetFramesValue::Foot.snap(at - 1, sr, rate, 1), at); + } + assert_eq!(FeetFramesValue::QuarterFrame.snap(50_049, sr, rate, 1), 50_050); + assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]); + } + + /// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins. + #[test] + fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() { + let sr = SampleRate::new(8_000).unwrap(); + let rate = FrameRate::Fps120; + for origin in [i64::MIN, -1, 0, 1, i64::MAX] { + assert_eq!(FeetFramesValue::SubFrame.lines(-3, 4, sr, rate, origin, 20), [-3, -2, -1, 0, 1, 2, 3]); + for at in [i64::MIN, -1, 0, 1, i64::MAX] { + assert_eq!(FeetFramesValue::SubFrame.line_at_or_before(at, sr, rate, origin), at); + assert_eq!(FeetFramesValue::SubFrame.snap(at, sr, rate, origin), at); + } + } + } + + #[test] + fn grid_lines_are_their_own_line_at_odd_tempos() { + let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30); + for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] { + let map = TempoMap::new(bpm, 4, 4).unwrap(); + let mut line = 0; + for _ in 0..64 { + assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm"); + line = g.next_line(line, sr, &map, rate); + } + } + } + + /// Musical boundaries stay exact across a tempo change while absolute scales remain + /// independent of the tempo map. + #[test] + fn grids_stay_aligned_across_a_tempo_change() { + let (sr, rate) = (SampleRate::HZ_48000, FrameRate::Fps29_97Drop); + let mut map = TempoMap::default(); + map.set_tempo(TICKS_PER_QUARTER * 4, 90.0).unwrap(); + let musical = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: false }; + for tick in [0, TICKS_PER_QUARTER * 3, TICKS_PER_QUARTER * 4, TICKS_PER_QUARTER * 5] { + let line = map.tick_to_samples(tick, sr); + assert_eq!(musical.line_at_or_before(line, sr, &map, rate), line); + } + for absolute in [ + GridValue::Seconds(0.5), + GridValue::Timecode(TimecodeValue::Second), + GridValue::FeetFrames(FeetFramesValue::Foot), + GridValue::Samples(1_000), + ] { + let line = absolute.next_line(absolute.line_at_or_before(100_000, sr, &map, rate), sr, &map, rate); + assert_eq!(absolute.line_at_or_before(line, sr, &map, rate), line, "{absolute:?}"); + } + } + + #[test] + fn sixteenth_grid_at_120() { + let g = GridValue::default(); + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30); + assert_eq!(g.step_samples(0, sr, &map, rate), 6000); + assert_eq!(g.snap(2900, sr, &map, rate), 0); + assert_eq!(g.snap(3100, sr, &map, rate), 6000); + assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]); + } + + /// Absolute increments report their expected sample lengths at a fixed rate. + #[test] + fn absolute_grids() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000); + assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920); + assert_eq!(GridValue::Timecode(TimecodeValue::Second).step_samples(0, sr, &map, rate), 48_000); + assert_eq!(GridValue::Timecode(TimecodeValue::FiveFrames).step_samples(0, sr, &map, rate), 9_600); + assert_eq!(GridValue::Timecode(TimecodeValue::HalfFrame).step_samples(0, sr, &map, rate), 960); + assert_eq!(GridValue::Timecode(TimecodeValue::QuarterFrame).step_samples(0, sr, &map, rate), 480); + assert_eq!(GridValue::Timecode(TimecodeValue::SubFrame).step_samples(0, sr, &map, rate), 19); + assert_eq!(GridValue::FeetFrames(FeetFramesValue::Foot).step_samples(0, sr, &map, rate), 30_720); + assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1); + } + + /// Bar length follows the meter while a whole note always spans four quarter notes. + #[test] + fn a_bar_and_a_whole_note_differ_in_three_four() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::new(120.0, 3, 4).unwrap(), FrameRate::Fps25); + let note = |value| GridValue::Note { value, dotted: false, triplet: false }; + assert_eq!(note(NoteValue::Bar).step_samples(0, sr, &map, rate), 72_000); + assert_eq!(note(NoteValue::Whole).step_samples(0, sr, &map, rate), 96_000); + assert_eq!(note(NoteValue::Bar).line_at_or_before(96_000, sr, &map, rate), 72_000); + assert_eq!(note(NoteValue::Whole).line_at_or_before(96_000, sr, &map, rate), 96_000); + assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]); + } + + #[test] + fn seconds_grid_lines_land_on_whole_seconds() { + let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30); + for g in [GridValue::Seconds(0.001), GridValue::Seconds(0.01)] { + for n in 0..20 { + let s = n * 44_100; + assert_eq!(g.snap(s, sr, &map, rate), s, "{g:?} at {n} s"); + assert_eq!(g.line_at_or_before(s, sr, &map, rate), s, "{g:?} at {n} s"); + } + } + } + + /// Legacy frame grids stay aligned to exact frame starts at fractional rates. + #[test] + fn frame_grid_lines_land_on_frame_starts() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] { + for f in [1, 10] { + let g = GridValue::Frames(f); + for k in (0..5000).step_by(37) { + let s = rate.samples_at_frame(k, sr); + let line = rate.samples_at_frame(k.div_euclid(f) * f, sr); + assert_eq!(g.line_at_or_before(s, sr, &map, rate), line, "{rate:?} {f} frames at frame {k}"); + if k % f == 0 { + assert_eq!(g.snap(s, sr, &map, rate), s, "{rate:?} {f} frames at frame {k}"); + } + } + let start = rate.samples_at_frame(4000, sr); + let end = rate.samples_at_frame(4000 + 20 * f, sr); + let want: Vec<_> = (0..20).map(|i| rate.samples_at_frame(4000 + i * f, sr)).collect(); + assert_eq!(g.lines(start, end, sr, &map, rate, 100), want, "{rate:?} {f} frames"); + } + } + } + + /// Named whole- and sub-frame grids retain the exact-multiple calculation used by legacy frame grids. + #[test] + fn named_frame_grids_land_on_exact_subdivisions() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + let grids = TimecodeValue::ALL.into_iter().map(GridValue::Timecode).chain(FeetFramesValue::ALL.into_iter().map(GridValue::FeetFrames)); + for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] { + for grid in grids.clone() { + for index in 0..200 { + let line = grid.abs_line(index, sr, rate); + assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{grid:?} {rate:?} line {index}"); + assert_eq!(grid.next_line(line, sr, &map, rate), grid.abs_line(index + 1, sr, rate), "{grid:?} {rate:?} line {index}"); + } + } + } + } + + /// Drop-frame second grids follow labelled second boundaries in both timeline directions. + #[test] + fn drop_frame_second_grids_follow_timecode_labels() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + let grid = GridValue::Timecode(TimecodeValue::Second); + for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + for seconds in [0, 59, 60, 61, 600, 601, 3600] { + let frame = GridValue::drop_second_frame(seconds, rate); + let line = rate.samples_at_frame(frame, sr); + assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}"); + assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}"); + for offset in -2..=rate.nominal() + 2 { + let position = rate.samples_at_frame(frame.saturating_add(offset), sr); + let before = grid.line_at_or_before(position, sr, &map, rate); + assert!(before <= position, "{rate:?} second {seconds}, frame offset {offset}"); + assert!(grid.next_line(before, sr, &map, rate) > position, "{rate:?} second {seconds}, frame offset {offset}"); + } + } + let labelled = Timecode { negative: false, hours: 0, minutes: 1, seconds: 1, frames: 0 }.to_samples(rate, sr); + assert_eq!(grid.snap(labelled, sr, &map, rate), labelled); + } + + for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1800), (FrameRate::Fps59_94Drop, 3600)] { + let boundary = rate.samples_at_frame(minute_frame, sr); + assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary"); + assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap"); + assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary"); + assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap"); + } + } + + #[test] + fn lines_are_capped() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + assert_eq!(GridValue::Samples(1).lines(0, 1_000_000, sr, &map, rate, 50).len(), 50); + } + + #[test] + fn triplets_and_dots() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + let t = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: true }; + assert_eq!(t.step_samples(0, sr, &map, rate), 16_000); + let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }; + assert_eq!(d.step_samples(0, sr, &map, rate), 36_000); + } + + /// Stepping follows each menu ladder and stops at its endpoints. + #[test] + fn values_step_to_the_next_larger_or_smaller() { + let note = |value, triplet| GridValue::Note { value, dotted: false, triplet }; + assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false)); + assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true)); + assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false)); + assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false)); + assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(1.0).stepped(true), GridValue::Seconds(1.0)); + assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10)); + assert_eq!(GridValue::Timecode(TimecodeValue::Frame).stepped(false), GridValue::Timecode(TimecodeValue::HalfFrame)); + assert_eq!(GridValue::FeetFrames(FeetFramesValue::Frame).stepped(true), GridValue::FeetFrames(FeetFramesValue::Foot)); + assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10)); + assert_eq!(GridValue::Samples(1).stepped(true), GridValue::Samples(2)); + assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1)); + } + + #[test] + fn snap_at_extreme_position_does_not_overflow() { + let sr = SampleRate::new(8000).unwrap(); + let map = TempoMap::new(1000.0, 4, 4).unwrap(); + let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30); + } +} diff --git a/crates/time/src/soundcraft/mod.rs b/crates/time/src/soundcraft/mod.rs new file mode 100644 index 000000000..bd4df3aa9 --- /dev/null +++ b/crates/time/src/soundcraft/mod.rs @@ -0,0 +1,155 @@ +//! SoundCraft timebases. +//! +//! Every position in a session is an absolute sample index (`i64`) at the session sample rate. +//! This crate converts those positions to and from the five Pro Tools-style timebases +//! (Bars|Beats, Min:Secs, Timecode, Feet+Frames, Samples) through a [`TempoMap`], and computes +//! grid lines and snapping for the Grid / Nudge values. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod format; +mod grid; +mod tempo; +mod timecode; + +pub use format::{TimeFormat, format_length, format_position, parse_position}; +pub use grid::{FeetFramesValue, GridScale, GridValue, NoteValue, TimecodeValue}; +pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap}; +pub use timecode::{FrameRate, Timecode}; + +/// A position or length in samples. +pub type Samples = i64; + +/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz). +/// Clamping there keeps every later addition and subtraction on positions far away from overflow. +pub const MAX_POSITION: Samples = 1 << 40; + +/// Errors from parsing or converting time values. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum TimeError { + #[error("cannot parse `{0}` as a {1} value")] + Parse(String, &'static str), + #[error("invalid sample rate {0}")] + SampleRate(u32), + #[error("invalid tempo {0}")] + Tempo(f64), + #[error("invalid meter {0}/{1}")] + Meter(u32, u32), +} + +/// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SampleRate(u32); + +impl SampleRate { + pub const HZ_44100: SampleRate = SampleRate(44_100); + pub const HZ_48000: SampleRate = SampleRate(48_000); + pub const COMMON: [u32; 8] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000]; + + pub fn new(hz: u32) -> Result { + if (8_000..=768_000).contains(&hz) { Ok(SampleRate(hz)) } else { Err(TimeError::SampleRate(hz)) } + } + pub fn hz(self) -> u32 { + self.0 + } + pub fn as_f64(self) -> f64 { + f64::from(self.0) + } + /// Samples → seconds. + pub fn seconds(self, s: Samples) -> f64 { + s as f64 / self.as_f64() + } + /// Seconds → samples, rounded to nearest and saturated. + pub fn samples(self, seconds: f64) -> Samples { + to_samples(seconds * self.as_f64()) + } +} + +impl Default for SampleRate { + fn default() -> Self { + SampleRate::HZ_48000 + } +} + +/// Round a float sample count to `i64`, mapping NaN to 0 and saturating infinities. +pub fn to_samples(v: f64) -> Samples { + if v.is_nan() { + 0 + } else if v >= i64::MAX as f64 { + i64::MAX + } else if v <= i64::MIN as f64 { + i64::MIN + } else { + v.round() as i64 + } +} + +/// A half-open sample range `[start, end)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub struct Range { + pub start: Samples, + pub end: Samples, +} + +impl Range { + pub fn new(a: Samples, b: Samples) -> Self { + if a <= b { Range { start: a, end: b } } else { Range { start: b, end: a } } + } + pub fn point(at: Samples) -> Self { + Range { start: at, end: at } + } + pub fn len(&self) -> Samples { + self.end.saturating_sub(self.start) + } + pub fn is_empty(&self) -> bool { + self.end <= self.start + } + pub fn contains(&self, s: Samples) -> bool { + s >= self.start && s < self.end + } + pub fn overlaps(&self, o: &Range) -> bool { + self.start < o.end && o.start < self.end + } + pub fn intersect(&self, o: &Range) -> Option { + let s = self.start.max(o.start); + let e = self.end.min(o.end); + (s < e).then_some(Range { start: s, end: e }) + } + pub fn shifted(&self, by: Samples) -> Range { + Range { start: self.start.saturating_add(by), end: self.end.saturating_add(by) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_rate_bounds() { + assert!(SampleRate::new(48_000).is_ok()); + assert!(SampleRate::new(0).is_err()); + assert!(SampleRate::new(10_000_000).is_err()); + } + + #[test] + fn seconds_round_trip() { + let sr = SampleRate::HZ_44100; + assert_eq!(sr.samples(1.0), 44_100); + assert!((sr.seconds(22_050) - 0.5).abs() < 1e-12); + } + + #[test] + fn to_samples_is_total() { + assert_eq!(to_samples(f64::NAN), 0); + assert_eq!(to_samples(f64::INFINITY), i64::MAX); + assert_eq!(to_samples(f64::NEG_INFINITY), i64::MIN); + } + + #[test] + fn range_ops() { + let a = Range::new(10, 0); + assert_eq!(a, Range { start: 0, end: 10 }); + assert_eq!(a.intersect(&Range::new(5, 20)), Some(Range::new(5, 10))); + assert_eq!(a.intersect(&Range::new(10, 20)), None); + assert!(a.contains(9) && !a.contains(10)); + } +} diff --git a/crates/time/src/soundcraft/tempo.rs b/crates/time/src/soundcraft/tempo.rs new file mode 100644 index 000000000..10f44b8dc --- /dev/null +++ b/crates/time/src/soundcraft/tempo.rs @@ -0,0 +1,622 @@ +//! Tempo and meter maps: the conversion between musical time (ticks) and samples. + +use super::{MAX_POSITION, SampleRate, Samples, TimeError, to_samples}; + +/// Ticks per quarter note (Pro Tools' resolution). +pub const TICKS_PER_QUARTER: i64 = 960; + +/// A tempo change at a tick position. `bpm` is quarter notes per minute. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TempoEvent { + pub tick: i64, + pub bpm: f64, +} + +/// A meter (time signature) change at a tick position, which must fall on a bar line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MeterEvent { + pub tick: i64, + pub numerator: u32, + pub denominator: u32, +} + +impl MeterEvent { + pub fn ticks_per_beat(&self) -> i64 { + let d = i64::from(self.denominator.max(1)); + (TICKS_PER_QUARTER * 4 / d).max(1) + } + pub fn ticks_per_bar(&self) -> i64 { + self.ticks_per_beat().saturating_mul(i64::from(self.numerator.max(1))) + } +} + +/// A musical position: 1-based bar and beat plus ticks within the beat. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BarBeat { + pub bar: i64, + pub beat: i64, + pub tick: i64, +} + +/// Tempo and meter changes for a session. Always has at least one of each, at tick 0. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(from = "TempoMapData")] +pub struct TempoMap { + tempos: Vec, + meters: Vec, + /// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm, + /// summed over the earlier segments). Lets tick/sample conversions binary-search the segment + /// instead of walking every earlier tempo change, so converting many positions against a large + /// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes. + #[serde(skip)] + elapsed: Vec, +} + +/// The serialized form of [`TempoMap`], without the derived lookup table. +#[derive(serde::Deserialize)] +struct TempoMapData { + tempos: Vec, + meters: Vec, +} + +impl From for TempoMap { + fn from(d: TempoMapData) -> Self { + TempoMap::from_parts(d.tempos, d.meters) + } +} + +impl Default for TempoMap { + fn default() -> Self { + TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]) + } +} + +fn valid_bpm(bpm: f64) -> Result { + if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) } +} + +fn valid_meter(numerator: u32, denominator: u32) -> bool { + (1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator) +} + +impl TempoMap { + fn from_parts(tempos: Vec, meters: Vec) -> Self { + let mut m = TempoMap { tempos, meters, elapsed: Vec::new() }; + m.reindex(); + m + } + + /// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo. + fn reindex(&mut self) { + let mut elapsed = Vec::with_capacity(self.tempos.len()); + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for (i, e) in self.tempos.iter().enumerate() { + if i > 0 { + // Saturating: a file's ticks are indexed before `sanitize` orders them. + acc += e.tick.saturating_sub(seg_tick) as f64 / bpm; + seg_tick = e.tick; + bpm = e.bpm; + } + elapsed.push(acc); + } + self.elapsed = elapsed; + } + + /// Samples per quarter-note minute (the unit of `elapsed`) at `sr`. + fn samples_per_elapsed(sr: SampleRate) -> f64 { + sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64 + } + + /// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for + /// the first), bpm and elapsed time at its start. + fn segment(&self, i: usize) -> (i64, f64, f64) { + let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm); + let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) }; + (tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0)) + } + + pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result { + let mut m = TempoMap::default(); + m.set_tempo(0, bpm)?; + m.set_meter(0, numerator, denominator)?; + Ok(m) + } + + pub fn tempos(&self) -> &[TempoEvent] { + &self.tempos + } + pub fn meters(&self) -> &[MeterEvent] { + &self.meters + } + + /// Repair a map read from a file: only valid tempos and meters within `0..=MAX_POSITION` ticks, in + /// tick order, with one of each at tick 0. + pub fn sanitize(&mut self) { + self.tempos.retain(|e| (0..=MAX_POSITION).contains(&e.tick) && valid_bpm(e.bpm).is_ok()); + self.tempos.sort_by_key(|e| e.tick); + self.tempos.dedup_by_key(|e| e.tick); + if self.tempos.first().is_none_or(|e| e.tick > 0) { + let bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + self.tempos.insert(0, TempoEvent { tick: 0, bpm }); + } + self.meters.retain(|m| (0..=MAX_POSITION).contains(&m.tick) && valid_meter(m.numerator, m.denominator)); + self.meters.sort_by_key(|m| m.tick); + self.meters.dedup_by_key(|m| m.tick); + if self.meters.first().is_none_or(|m| m.tick > 0) { + let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator)); + self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator }); + } + self.reindex(); + } + + /// Insert or replace the tempo at `tick`. + pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> { + let bpm = valid_bpm(bpm)?; + let tick = tick.max(0); + match self.tempos.binary_search_by(|e| e.tick.cmp(&tick)) { + Ok(i) => { + if let Some(e) = self.tempos.get_mut(i) { + e.bpm = bpm; + } + } + Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }), + } + self.reindex(); + Ok(()) + } + + /// Remove a tempo event (the one at tick 0 is kept). + pub fn remove_tempo(&mut self, tick: i64) -> bool { + if tick == 0 { + return false; + } + let before = self.tempos.len(); + self.tempos.retain(|e| e.tick != tick); + self.reindex(); + before != self.tempos.len() + } + + /// Insert or replace the meter at `tick` (snapped to the bar line at or before it). + /// Later meter changes keep their bar numbers, so they stay on bar lines. + pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> { + if !valid_meter(numerator, denominator) { + return Err(TimeError::Meter(numerator, denominator)); + } + let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) }; + let later = self.take_meters_after(tick); + self.put_meter(MeterEvent { tick, numerator, denominator }); + self.restore_meters_at_bars(later); + Ok(()) + } + + /// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers. + pub fn remove_meter(&mut self, tick: i64) -> bool { + if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) { + return false; + } + let later = self.take_meters_after(tick); + self.meters.retain(|e| e.tick != tick); + self.restore_meters_at_bars(later); + true + } + + fn put_meter(&mut self, ev: MeterEvent) { + match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) { + Ok(i) => { + if let Some(e) = self.meters.get_mut(i) { + *e = ev; + } + } + Err(i) => self.meters.insert(i, ev), + } + } + + /// Remove the meters after `tick`, returning each with the bar it starts: the bar + /// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one + /// walk per meter, so a session with many meter changes edits in linear time. + fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> { + let mut later = Vec::new(); + let (mut bar, mut seg_tick) = (1i64, 0i64); + for (i, m) in self.meters.iter().enumerate() { + let tpb = m.ticks_per_bar(); + if m.tick > tick { + later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m)); + } + if let Some(n) = self.meters.get(i + 1) { + bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb)); + seg_tick = n.tick; + } + } + self.meters.retain(|e| e.tick <= tick); + later + } + + /// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends. + fn last_segment(&self) -> (i64, i64) { + let (mut cur_bar, mut cur_tick) = (1i64, 0i64); + for (i, m) in self.meters.iter().enumerate() { + if let Some(n) = self.meters.get(i + 1) { + cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar())); + cur_tick = n.tick; + } + } + (cur_bar, cur_tick) + } + + /// Put meters back at the start of their bars under the current earlier meters, in bar order. + /// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar + /// before it (meters off their bar lines) takes the full walk. + fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) { + let (mut cur_bar, mut cur_tick) = self.last_segment(); + for (bar, ev) in later { + let tick = if bar >= cur_bar { + let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar(); + cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb)) + } else { + self.bar_start_tick(bar) + }; + self.put_meter(MeterEvent { tick, ..ev }); + (cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() }; + } + } + + /// Tempo in effect at `tick`. + pub fn tempo_at_tick(&self, tick: i64) -> f64 { + let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + for e in &self.tempos { + if e.tick <= tick { + bpm = e.bpm; + } else { + break; + } + } + bpm + } + + /// Meter in effect at `tick`. + pub fn meter_at_tick(&self, tick: i64) -> MeterEvent { + let mut m = self.meters.first().copied().unwrap_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }); + for e in &self.meters { + if e.tick <= tick { + m = *e; + } else { + break; + } + } + m + } + + fn samples_per_tick(bpm: f64, sr: SampleRate) -> f64 { + sr.as_f64() * 60.0 / (bpm * TICKS_PER_QUARTER as f64) + } + + /// Ticks → samples (exact for constant-tempo segments). + pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples { + to_samples(self.tick_to_samples_f(tick, sr)) + } + + fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 { + // Tempo events after the first that start before `tick` (the segments it has passed). + let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr) + } + + /// Samples → ticks (fractional). + pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 { + let s = s as f64; + let scale = Self::samples_per_elapsed(sr); + // Tempo events after the first that start at or before `s`. + let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s)); + let (seg_tick, bpm, elapsed) = self.segment(passed); + seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr) + } + + pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 { + to_samples(self.samples_to_ticks_f(s, sr)) + } + + /// Tick of the first beat of `bar` (1-based; bars before 1 extend the first meter backwards). + pub fn bar_start_tick(&self, bar: i64) -> i64 { + let mut cur_bar = 1i64; + let mut cur_tick = 0i64; + let first = self.meter_at_tick(0); + if bar < 1 { + return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar()); + } + for (i, m) in self.meters.iter().enumerate() { + let next_tick = self.meters.get(i + 1).map(|n| n.tick); + let tpb = m.ticks_per_bar(); + let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb)); + match bars_in_seg { + Some(n) if bar >= cur_bar.saturating_add(n) => { + cur_bar = cur_bar.saturating_add(n); + cur_tick = next_tick.unwrap_or(cur_tick); + } + _ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)), + } + } + cur_tick + } + + /// Bar|beat|tick at `tick`. + pub fn bar_beat_at_tick(&self, tick: i64) -> BarBeat { + let first = self.meter_at_tick(0); + if tick < 0 { + let tpb = first.ticks_per_bar(); + let abs = tick.unsigned_abs(); + let tpb_u = tpb.unsigned_abs(); + let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u)); + let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX); + // Offset of `tick` inside its bar, without negating `i64::MIN`. + let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64; + let beat_t = first.ticks_per_beat(); + return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t }; + } + let mut bar = 1i64; + let mut seg_tick = 0i64; + for (i, m) in self.meters.iter().enumerate() { + let tpb = m.ticks_per_bar(); + if let Some(n) = self.meters.get(i + 1) + && tick >= n.tick + { + bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb)); + seg_tick = n.tick; + continue; + } + let rel = tick - seg_tick; + let bars = rel / tpb; + let within = rel % tpb; + let beat_t = m.ticks_per_beat(); + return BarBeat { bar: bar + bars, beat: within / beat_t + 1, tick: within % beat_t }; + } + BarBeat { bar, beat: 1, tick: 0 } + } + + /// Bar|beat|tick → tick. + pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 { + let start = self.bar_start_tick(bb.bar); + let m = self.meter_at_tick(start); + start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick) + } + + pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat { + self.bar_beat_at_tick(self.samples_to_ticks(s, sr)) + } + + pub fn samples_at_bar_beat(&self, bb: BarBeat, sr: SampleRate) -> Samples { + self.tick_to_samples(self.tick_at_bar_beat(bb), sr) + } + + /// Scale every tempo by `factor` (Tempo Operations › Scale). + pub fn scale_tempos(&mut self, factor: f64) -> Result<(), TimeError> { + let mut out = self.tempos.clone(); + for e in &mut out { + e.bpm = valid_bpm(e.bpm * factor)?; + } + self.tempos = out; + self.reindex(); + Ok(()) + } +} + +/// Ceiling division for a non-negative `num` and positive `den`, without overflow. +fn ceil_div(num: i64, den: i64) -> i64 { + num / den + i64::from(num % den != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_map_is_120_4_4() { + let m = TempoMap::default(); + let sr = SampleRate::HZ_48000; + // One bar of 4/4 at 120 bpm = 2 seconds. + assert_eq!(m.samples_at_bar_beat(BarBeat { bar: 2, beat: 1, tick: 0 }, sr), 96_000); + assert_eq!(m.bar_beat_at(96_000, sr), BarBeat { bar: 2, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); + } + + #[test] + fn meter_near_max_tick_does_not_overflow() { + let mut m = TempoMap::default(); + m.set_meter(i64::MAX, 7, 8).unwrap(); + let bb = m.bar_beat_at_tick(i64::MAX); + assert!(bb.bar > 1); + assert!(m.bar_start_tick(bb.bar) > 0); + assert!(m.bar_start_tick(i64::MAX) > 0); + } + + #[test] + fn minimum_bar_saturates_instead_of_overflowing() { + let m = TempoMap::default(); + assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN); + assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); + } + + #[test] + fn minimum_beat_is_clamped_without_overflow() { + let m = TempoMap::default(); + assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0); + } + + #[test] + fn bar_beat_at_min_tick_does_not_overflow() { + let m = TempoMap::default(); + let bb = m.bar_beat_at_tick(i64::MIN); + assert!(bb.bar < 1); + assert!(bb.beat >= 1 && bb.tick >= 0); + assert_eq!(m.bar_beat_at_tick(-1).bar, 0); + } + + #[test] + fn tempo_change_is_integrated() { + let mut m = TempoMap::default(); + let sr = SampleRate::HZ_48000; + m.set_tempo(TICKS_PER_QUARTER * 4, 60.0).unwrap(); + // Bar 1 at 120 = 96000 samples; bar 2 at 60 = 192000 samples. + assert_eq!(m.tick_to_samples(TICKS_PER_QUARTER * 8, sr), 96_000 + 192_000); + assert_eq!(m.samples_to_ticks(96_000 + 192_000, sr), TICKS_PER_QUARTER * 8); + } + + #[test] + fn meter_change_counts_bars() { + let mut m = TempoMap::default(); + // 2 bars of 4/4 then 3/4. + m.set_meter(TICKS_PER_QUARTER * 8, 3, 4).unwrap(); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 8), BarBeat { bar: 3, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 11), BarBeat { bar: 4, beat: 1, tick: 0 }); + assert_eq!(m.bar_start_tick(4), TICKS_PER_QUARTER * 11); + let bb = BarBeat { bar: 4, beat: 2, tick: 10 }; + assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb); + } + + #[test] + fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() { + // 200 000 meter changes, one per bar, built directly: a walk per meter would take + // minutes to edit here, and even to build through `set_meter`. + let mut m = TempoMap::new(120.0, 4, 4).unwrap(); + let mut tick = 0; + for bar in 1..200_000i64 { + let numerator = 3 + (bar % 2) as u32; + tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar); + m.meters.push(MeterEvent { tick, numerator, denominator: 4 }); + } + let sample = [1usize, 1_000, 199_998]; + let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar); + assert_eq!(bars(&m), [2, 1_001, 199_999]); + m.set_meter(0, 7, 8).unwrap(); + assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers"); + assert_eq!(m.meters.len(), 200_000); + assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick)); + assert!(m.remove_meter(m.meters[1].tick)); + assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]); + // Meters off their bar lines (counted as bars 2 and 4) land where they always did: on + // bar 2 of the new meter, then two bars of their own later. + let mut off = TempoMap::new(120.0, 4, 4).unwrap(); + off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 }); + off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 }); + off.set_meter(0, 2, 4).unwrap(); + let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect(); + assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]); + } + + #[test] + fn eighth_note_meters() { + let m = TempoMap::new(120.0, 6, 8).unwrap(); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 3), BarBeat { bar: 2, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 }); + } + + #[test] + fn sanitize_keeps_what_the_setters_accept() { + let tempo = |tick, bpm| TempoEvent { tick, bpm }; + let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator }; + let mut m = TempoMap::from_parts( + vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], + vec![meter(0, 0, 4), meter(3840, 3, 4)], + ); + m.sanitize(); + assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]); + assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]); + let mut empty = TempoMap::from_parts(Vec::new(), Vec::new()); + empty.sanitize(); + assert_eq!(empty, TempoMap::default()); + } + + #[test] + fn sanitize_drops_events_beyond_the_position_range() { + let mut m = TempoMap::from_parts( + vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }], + vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }], + ); + m.sanitize(); + assert_eq!(m, TempoMap::default()); + assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4); + } + + #[test] + fn rejects_bad_values() { + let mut m = TempoMap::default(); + assert!(m.set_tempo(0, f64::NAN).is_err()); + assert!(m.set_tempo(0, 0.0).is_err()); + assert!(m.set_meter(0, 0, 4).is_err()); + assert!(m.set_meter(0, 4, 3).is_err()); + assert!(!m.remove_tempo(0)); + } + + #[test] + fn negative_positions() { + let m = TempoMap::default(); + assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 }); + assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 }); + } + + /// The segment walk the indexed conversions replaced, kept as the reference they must match. + fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 { + let mut acc = 0.0; + let mut seg_tick = 0i64; + let mut bpm = m.tempos[0].bpm; + for e in m.tempos.iter().skip(1) { + if e.tick >= tick { + break; + } + acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr); + seg_tick = e.tick; + bpm = e.bpm; + } + to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr)) + } + + #[test] + fn indexed_conversions_match_the_segment_walk() { + let sr = SampleRate::HZ_48000; + let mut m = TempoMap::default(); + for i in 1..200i64 { + m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap(); + } + assert!(m.remove_tempo(487 * 50)); + for tick in (-1000..200 * 487 + 5000).step_by(113) { + let s = m.tick_to_samples(tick, sr); + assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}"); + assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}"); + } + let json = serde_json::to_string(&m).unwrap(); + assert!(!json.contains("elapsed")); + let back: TempoMap = serde_json::from_str(&json).unwrap(); + assert_eq!(back, m); + assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr)); + } + + #[test] + fn a_file_with_wild_ticks_is_indexed_without_overflow() { + let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#; + let mut m: TempoMap = serde_json::from_str(json).unwrap(); + m.sanitize(); + assert_eq!(m.tempos().first().map(|e| e.tick), Some(0)); + assert_eq!(m.elapsed.len(), m.tempos().len()); + } + + #[test] + fn conversions_against_a_large_tempo_map_stay_fast() { + // 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks + // the earlier segments, well under a second when it binary-searches them. + let sr = SampleRate::HZ_48000; + let n = 200_000i64; + let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect(); + let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]); + m.sanitize(); + let start = std::time::Instant::now(); + let mut sum = 0i64; + for i in 0..n { + let s = m.tick_to_samples(i * 10 + 5, sr); + sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr)); + } + assert!(sum != 0); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + } +} diff --git a/crates/time/src/soundcraft/timecode.rs b/crates/time/src/soundcraft/timecode.rs new file mode 100644 index 000000000..03eb1cc94 --- /dev/null +++ b/crates/time/src/soundcraft/timecode.rs @@ -0,0 +1,309 @@ +//! SMPTE timecode and Feet+Frames. + +use super::{SampleRate, Samples, TimeError}; + +/// Session timecode rates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FrameRate { + Fps23_976, + #[default] + Fps24, + Fps25, + Fps29_97, + Fps29_97Drop, + Fps30, + Fps47_952, + Fps48, + Fps50, + Fps59_94, + Fps59_94Drop, + Fps60, + Fps100, + Fps119_88, + Fps120, +} + +impl FrameRate { + pub const ALL: [FrameRate; 15] = [ + FrameRate::Fps23_976, + FrameRate::Fps24, + FrameRate::Fps25, + FrameRate::Fps29_97, + FrameRate::Fps29_97Drop, + FrameRate::Fps30, + FrameRate::Fps47_952, + FrameRate::Fps48, + FrameRate::Fps50, + FrameRate::Fps59_94, + FrameRate::Fps59_94Drop, + FrameRate::Fps60, + FrameRate::Fps100, + FrameRate::Fps119_88, + FrameRate::Fps120, + ]; + + /// Real frames per second as a rational (num, den). + pub fn rational(self) -> (i64, i64) { + match self { + FrameRate::Fps23_976 => (24_000, 1001), + FrameRate::Fps24 => (24, 1), + FrameRate::Fps25 => (25, 1), + FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001), + FrameRate::Fps30 => (30, 1), + FrameRate::Fps47_952 => (48_000, 1001), + FrameRate::Fps48 => (48, 1), + FrameRate::Fps50 => (50, 1), + FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001), + FrameRate::Fps60 => (60, 1), + FrameRate::Fps100 => (100, 1), + FrameRate::Fps119_88 => (120_000, 1001), + FrameRate::Fps120 => (120, 1), + } + } + + /// Frames per labelled second (the counting base). + pub fn nominal(self) -> i64 { + let (n, d) = self.rational(); + (n + d / 2) / d + } + + pub fn is_drop(self) -> bool { + matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop) + } + + pub fn label(self) -> &'static str { + match self { + FrameRate::Fps23_976 => "23.976", + FrameRate::Fps24 => "24", + FrameRate::Fps25 => "25", + FrameRate::Fps29_97 => "29.97", + FrameRate::Fps29_97Drop => "29.97 Drop", + FrameRate::Fps30 => "30", + FrameRate::Fps47_952 => "47.952", + FrameRate::Fps48 => "48", + FrameRate::Fps50 => "50", + FrameRate::Fps59_94 => "59.94", + FrameRate::Fps59_94Drop => "59.94 Drop", + FrameRate::Fps60 => "60", + FrameRate::Fps100 => "100", + FrameRate::Fps119_88 => "119.88", + FrameRate::Fps120 => "120", + } + } + + /// Whole frame index at a sample position (floored). + pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 { + let (n, d) = self.rational(); + let num = i128::from(s) * i128::from(n); + let den = i128::from(sr.hz()) * i128::from(d); + if den == 0 { + return 0; + } + i64::try_from(num.div_euclid(den)).unwrap_or(0) + } + + /// Sample position of the start of frame `f`. + pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples { + let (n, d) = self.rational(); + if n == 0 { + return 0; + } + // Round half away from zero so negative frames mirror positive ones. + let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); + let v = if f < 0 { -mag } else { mag }; + i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) + } + + /// Samples per frame (fractional). + pub fn samples_per_frame(self, sr: SampleRate) -> f64 { + let (n, d) = self.rational(); + sr.as_f64() * d as f64 / n as f64 + } +} + +/// A timecode label. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Timecode { + pub negative: bool, + pub hours: i64, + pub minutes: i64, + pub seconds: i64, + pub frames: i64, +} + +impl Timecode { + /// Label for an absolute frame count. + pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode { + let negative = frames < 0; + // Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before + // taking the magnitude so i64::MIN is represented exactly too. + let mut f = i128::from(frames).abs(); + let base = i128::from(rate.nominal().max(1)); + if rate.is_drop() { + // Drop 2 (or 4) labels each minute except every tenth. + let drop = if base >= 60 { 4 } else { 2 }; + let per_10min = base * 600 - drop * 9; + let per_min = base * 60 - drop; + let tens = f / per_10min; + let rem = f % per_10min; + let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens }; + f += extra; + } + let frames_l = f % base; + let total_secs = f / base; + Timecode { + negative, + hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX), + minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0), + seconds: i64::try_from(total_secs % 60).unwrap_or(0), + frames: i64::try_from(frames_l).unwrap_or(0), + } + } + + /// Absolute frame count of this label. + pub fn to_frames(&self, rate: FrameRate) -> i64 { + let base = rate.nominal().max(1); + let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes); + let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames); + if rate.is_drop() { + let drop = if base >= 60 { 4 } else { 2 }; + f -= drop * (total_min - total_min / 10); + } + let f = if self.negative { -f } else { f }; + i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX }) + } + + pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode { + Timecode::from_frames(rate.frames_at(s, sr), rate) + } + + pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples { + rate.samples_at_frame(self.to_frames(rate), sr) + } + + pub fn format(&self, rate: FrameRate) -> String { + let sep = if rate.is_drop() { ';' } else { ':' }; + let sign = if self.negative { "-" } else { "" }; + format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames) + } + + /// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned). + pub fn parse(text: &str) -> Result { + let t = text.trim(); + let (negative, t) = match t.strip_prefix('-') { + Some(r) => (true, r), + None => (false, t), + }; + let parts: Vec<&str> = t.split([':', ';', '.']).collect(); + if parts.is_empty() || parts.len() > 4 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } + let mut nums = [0i64; 4]; + let offset = 4 - parts.len(); + for (i, p) in parts.iter().enumerate() { + let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?; + if v < 0 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } + if let Some(slot) = nums.get_mut(offset + i) { + *slot = v; + } + } + Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] }) + } +} + +/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0. +pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) { + let f = rate.frames_at(s, sr).saturating_add(start); + (f.div_euclid(16), f.rem_euclid(16)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_drop_round_trip() { + let sr = SampleRate::HZ_48000; + let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr); + assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00"); + assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661); + } + + #[test] + fn negative_timecode_rounds_symmetrically() { + let sr = SampleRate::HZ_48000; + let tc = Timecode::parse("-00:00:01:00").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000); + for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] { + for f in [1i64, 7, 25, 1799, 107_892] { + assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}"); + } + } + } + + #[test] + fn drop_frame_labels() { + let r = FrameRate::Fps29_97Drop; + // Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02. + assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02"); + assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29"); + // Minute 10 keeps its labels. + assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00"); + for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] { + assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}"); + } + } + + #[test] + fn huge_timecode_saturates_instead_of_overflowing() { + let sr = SampleRate::HZ_48000; + for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] { + let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MAX); + let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MIN); + } + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400); + } + + #[test] + fn extreme_frame_counts_round_trip_at_every_rate() { + for rate in FrameRate::ALL { + for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] { + let tc = Timecode::from_frames(frames, rate); + assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}"); + assert_eq!(tc.negative, frames < 0); + assert!((0..60).contains(&tc.minutes)); + assert!((0..60).contains(&tc.seconds)); + assert!((0..rate.nominal()).contains(&tc.frames)); + assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc); + } + } + } + + #[test] + fn extreme_drop_frame_labels_do_not_overflow() { + for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX); + } + } + + #[test] + fn parse_variants() { + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4)); + let tc = Timecode::parse("3:04").unwrap(); + assert_eq!((tc.seconds, tc.frames), (3, 4)); + assert!(Timecode::parse("a:b").is_err()); + assert!(Timecode::parse("1:2:3:4:5").is_err()); + } + + #[test] + fn feet_and_frames() { + let sr = SampleRate::HZ_48000; + assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8)); + } +} From 88aa0bed6a315c10203a5d0ea51652a4c7dc82d2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:07:08 +0000 Subject: [PATCH 004/118] Add shared SoundCraft session format --- Cargo.lock | 14 + Cargo.toml | 1 + README.md | 1 + crates/scraft-format/Cargo.toml | 24 + crates/scraft-format/README.md | 15 + crates/scraft-format/src/automation.rs | 394 +++++++ crates/scraft-format/src/b64.rs | 114 ++ crates/scraft-format/src/bundle.rs | 250 +++++ crates/scraft-format/src/clip.rs | 544 ++++++++++ crates/scraft-format/src/ids.rs | 21 + crates/scraft-format/src/lib.rs | 53 + crates/scraft-format/src/markers.rs | 58 ++ crates/scraft-format/src/mixer.rs | 714 +++++++++++++ crates/scraft-format/src/session.rs | 1319 ++++++++++++++++++++++++ crates/scraft-format/src/source.rs | 204 ++++ crates/scraft-format/src/track.rs | 477 +++++++++ crates/zip/src/archive.rs | 6 + crates/zip/src/lib.rs | 16 + 18 files changed, 4225 insertions(+) create mode 100644 crates/scraft-format/Cargo.toml create mode 100644 crates/scraft-format/README.md create mode 100644 crates/scraft-format/src/automation.rs create mode 100644 crates/scraft-format/src/b64.rs create mode 100644 crates/scraft-format/src/bundle.rs create mode 100644 crates/scraft-format/src/clip.rs create mode 100644 crates/scraft-format/src/ids.rs create mode 100644 crates/scraft-format/src/lib.rs create mode 100644 crates/scraft-format/src/markers.rs create mode 100644 crates/scraft-format/src/mixer.rs create mode 100644 crates/scraft-format/src/session.rs create mode 100644 crates/scraft-format/src/source.rs create mode 100644 crates/scraft-format/src/track.rs diff --git a/Cargo.lock b/Cargo.lock index 2dbe34bdd..ae32f8b7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2145,6 +2145,20 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-scraft-format" +version = "0.1.0" +dependencies = [ + "craft-audio", + "craft-midi", + "craft-time", + "craft-zip", + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 23e3e5d46..65909c1cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ craft-raw = { path = "crates/raw" } craft-sheet = { path = "crates/sheet" } craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } +craft-scraft-format = { path = "crates/scraft-format" } craft-time = { path = "crates/time" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } diff --git a/README.md b/README.md index c56ce2bf0..ed2a8bddd 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit is built on it in `craft-pdf`. - [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft. - [`craft-time`](crates/time/README.md): exact rational ticks and SMPTE time across media, with a `soundcraft` compatibility module retaining SoundCraft's sample-index timebase, tempo maps, grids and session serialization semantics. +- [`craft-scraft-format`](crates/scraft-format/README.md): SoundCraft's typed `.scraft` session schema, versioned reader/writer, hostile-input validation, and `.scraftz` bundle reader/writer. Engine and UI mapping stays in SoundCraft. - [`craft-metadata`](crates/metadata/README.md): photo metadata, from LightCraft: EXIF (read and write, maker-note lens and focus point, every tag by name), IPTC-IIM, XMP packets (read, write, and a merge that keeps what another program wrote), GPX track logs, and the blocks that carry them in JPEG, PNG, diff --git a/crates/scraft-format/Cargo.toml b/crates/scraft-format/Cargo.toml new file mode 100644 index 000000000..9ec17cf9f --- /dev/null +++ b/crates/scraft-format/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "craft-scraft-format" +description = "SoundCraft's versioned native session document model and JSON schema" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-audio = { workspace = true, features = ["lossless-encode", "peaks"] } +craft-midi = { workspace = true, features = ["serde"] } +craft-time.workspace = true +craft-zip.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +craft-audio = { workspace = true, features = ["decode", "serde"] } +proptest.workspace = true diff --git a/crates/scraft-format/README.md b/crates/scraft-format/README.md new file mode 100644 index 000000000..c2df56206 --- /dev/null +++ b/crates/scraft-format/README.md @@ -0,0 +1,15 @@ +# craft-scraft-format + +The native SoundCraft session document, `.scraft` JSON schema and typed reader/writer. `Session` +and its nested types are the wire representation; the library checks `format_version` before +parsing, deserializes the document directly (without building a `serde_json::Value` tree), then +sanitizes and validates the resulting session. `Session::to_json` writes the same pretty JSON +representation. + +The crate retains SoundCraft's serialized enum names, defaults, compatibility behavior for older +fields, 256 MiB session byte limit, character limits, and duplicate/exhausted ID checks. Its time +fields use `craft_time::soundcraft`, the exact sample-index time API retained by `craft-time`. +MIDI sequence values use `craft-midi`'s serde representation. + +The application still maps sessions to UI state, engine state, media paths and audio pools. The +session schema and its format-specific parsing, writing, sanitization and validation live here. diff --git a/crates/scraft-format/src/automation.rs b/crates/scraft-format/src/automation.rs new file mode 100644 index 000000000..6d597682e --- /dev/null +++ b/crates/scraft-format/src/automation.rs @@ -0,0 +1,394 @@ +//! Automation lanes and modes. + +use crate::soundcraft_time::Samples; + +/// Automation modes (per track). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum AutomationMode { + Off, + #[default] + Read, + Touch, + Latch, + TouchLatch, + Write, + Trim, +} + +impl AutomationMode { + pub const ALL: [AutomationMode; 7] = [ + AutomationMode::Off, + AutomationMode::Read, + AutomationMode::Touch, + AutomationMode::Latch, + AutomationMode::TouchLatch, + AutomationMode::Write, + AutomationMode::Trim, + ]; + pub fn label(self) -> &'static str { + match self { + AutomationMode::Off => "off", + AutomationMode::Read => "read", + AutomationMode::Touch => "touch", + AutomationMode::Latch => "latch", + AutomationMode::TouchLatch => "touch/latch", + AutomationMode::Write => "write", + AutomationMode::Trim => "trim", + } + } + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + AutomationMode::ALL.into_iter().find(|m| m.label() == s || m.label().replace('/', "_") == s || format!("{m:?}").to_ascii_lowercase() == s) + } + pub fn reads(self) -> bool { + !matches!(self, AutomationMode::Off) + } +} + +/// Which parameter an automation lane controls. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum AutoParam { + /// Fader level, dB. + Volume, + /// Pan, -1..1 (index = which panner of a stereo track: 0 left, 1 right). + Pan(u8), + /// Mute (0/1). + Mute, + /// Send level, dB. + SendLevel(u8), + SendPan(u8), + SendMute(u8), + /// Insert slot + parameter id. + Plugin { + slot: u8, + param: String, + }, +} + +impl AutoParam { + pub fn label(&self) -> String { + match self { + AutoParam::Volume => "volume".into(), + AutoParam::Pan(0) => "pan".into(), + AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1), + AutoParam::Mute => "mute".into(), + AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)), + AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)), + AutoParam::SendMute(i) => format!("send {} mute", slot_letter(*i)), + AutoParam::Plugin { slot, param } => format!("insert {} {param}", slot_letter(*slot)), + } + } + /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`. + pub fn parse(s: &str) -> Option { + let orig = s.trim(); + let s = orig.to_ascii_lowercase(); + match s.as_str() { + "volume" | "vol" => return Some(AutoParam::Volume), + "pan" | "pan1" => return Some(AutoParam::Pan(0)), + "pan2" => return Some(AutoParam::Pan(1)), + "mute" => return Some(AutoParam::Mute), + _ => {} + } + if let Some(rest) = s.strip_prefix("send_") { + let mut it = rest.splitn(2, '_'); + let slot = letter_slot(it.next()?)?; + return match it.next()? { + "level" => Some(AutoParam::SendLevel(slot)), + "pan" => Some(AutoParam::SendPan(slot)), + "mute" => Some(AutoParam::SendMute(slot)), + _ => None, + }; + } + if let Some(rest) = s.strip_prefix("plugin:") { + let (slot, param) = rest.split_once(':')?; + // ASCII lowercasing keeps byte offsets, so take the param from the original. + let param = orig.get(orig.len() - param.len()..)?; + return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() }); + } + None + } + /// Value range and default. + pub fn range(&self) -> (f32, f32, f32) { + match self { + AutoParam::Volume | AutoParam::SendLevel(_) => (-144.0, 12.0, 0.0), + AutoParam::Pan(_) | AutoParam::SendPan(_) => (-1.0, 1.0, 0.0), + AutoParam::Mute | AutoParam::SendMute(_) => (0.0, 1.0, 0.0), + AutoParam::Plugin { .. } => (f32::MIN, f32::MAX, 0.0), + } + } +} + +pub fn slot_letter(i: u8) -> char { + char::from(b'A'.saturating_add(i.min(25))) +} + +fn letter_slot(s: &str) -> Option { + let c = s.chars().next()?.to_ascii_uppercase(); + if s.len() == 1 && c.is_ascii_uppercase() { Some(c as u8 - b'A') } else { s.parse().ok() } +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AutomationPoint { + pub at: Samples, + pub value: f32, +} + +/// A breakpoint automation lane. Points are kept sorted by time. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AutomationLane { + pub param: AutoParam, + pub points: Vec, + #[serde(default)] + pub enabled: bool, +} + +impl AutomationLane { + pub fn new(param: AutoParam) -> Self { + AutomationLane { param, points: Vec::new(), enabled: true } + } + + /// Value at `at` (linear interpolation; `default` when empty). + pub fn value_at(&self, at: Samples, default: f32) -> f32 { + let pts = &self.points; + let Some(first) = pts.first() else { return default }; + if at <= first.at { + return first.value; + } + let idx = pts.partition_point(|p| p.at <= at); + match (pts.get(idx.wrapping_sub(1)), pts.get(idx)) { + (Some(a), Some(b)) if b.at > a.at => { + // Mute-like lanes step instead of ramping. + if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) { + return a.value; + } + // Widen so far-apart breakpoints cannot overflow i64. + let span = i128::from(b.at) - i128::from(a.at); + let off = i128::from(at) - i128::from(a.at); + // Interpolate in f64: the f32 difference overflows for + // opposite-sign values near f32::MAX. + let (av, bv) = (f64::from(a.value), f64::from(b.value)); + (av + (bv - av) * (off as f64 / span as f64)) as f32 + } + (Some(a), _) => a.value, + _ => default, + } + } + + /// Insert or replace a breakpoint. + pub fn set_point(&mut self, at: Samples, value: f32) { + let (lo, hi, _) = self.param.range(); + let value = if value.is_nan() { 0.0 } else { value.clamp(lo, hi) }; + match self.points.binary_search_by(|p| p.at.cmp(&at)) { + Ok(i) => { + if let Some(p) = self.points.get_mut(i) { + p.value = value; + } + } + Err(i) => self.points.insert(i, AutomationPoint { at, value }), + } + } + + /// Remove points in `[start, end)`. + pub fn clear_range(&mut self, start: Samples, end: Samples) -> usize { + let before = self.points.len(); + self.points.retain(|p| p.at < start || p.at >= end); + before - self.points.len() + } + + /// Write a constant value over a range, keeping the surrounding curve intact with anchor points. + pub fn write_range(&mut self, start: Samples, end: Samples, value: f32, default: f32) { + if end <= start { + return; + } + let before = self.value_at(start.saturating_sub(1), default); + let after = self.value_at(end, default); + self.clear_range(start, end.saturating_add(1)); + if start > 0 { + self.set_point(start - 1, before); + } + self.set_point(start, value); + self.set_point(end, value); + let anchor = end.saturating_add(1); + // A real breakpoint already at `end + 1` keeps its own value. + if self.points.binary_search_by(|p| p.at.cmp(&anchor)).is_err() { + self.set_point(anchor, after); + } + } + + /// Thin: remove points whose removal changes the curve by less than `tolerance`. + pub fn thin(&mut self, tolerance: f32) -> usize { + // Linear thinning would move the transitions of step lanes. + if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) || self.points.len() < 3 || tolerance.is_nan() { + return 0; + } + let tolerance = f64::from(tolerance); + let mut keep = vec![true; self.points.len()]; + let mut last = 0usize; + // Slopes from the last kept point that keep every point removed since then within `tolerance`. + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for i in 1..self.points.len() - 1 { + let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue }; + let dt = b.at.saturating_sub(a.at) as f64; + let rise = f64::from(b.value) - f64::from(a.value); + lo = lo.max((rise - tolerance) / dt); + hi = hi.min((rise + tolerance) / dt); + let slope = (f64::from(c.value) - f64::from(a.value)) / c.at.saturating_sub(a.at) as f64; + if (lo..=hi).contains(&slope) { + if let Some(k) = keep.get_mut(i) { + *k = false; + } + } else { + last = i; + (lo, hi) = (f64::NEG_INFINITY, f64::INFINITY); + } + } + let before = self.points.len(); + let mut it = keep.iter(); + self.points.retain(|_| it.next().copied().unwrap_or(true)); + before - self.points.len() + } + + /// Shift points at or after `from` by `delta` (Shuffle edits, Insert Time). + pub fn shift_from(&mut self, from: Samples, delta: Samples) { + for p in &mut self.points { + if p.at >= from { + p.at = p.at.saturating_add(delta).max(0); + } + } + self.points.sort_by_key(|p| p.at); + self.points.dedup_by_key(|p| p.at); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_keeps_plugin_param_case() { + let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() }; + assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p)); + assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() })); + assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume)); + assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1))); + } + + #[test] + fn value_at_far_apart_points_does_not_overflow() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(-1, 0.0); + l.set_point(i64::MAX, -10.0); + let v = l.value_at(5, 0.0); + assert!(v.is_finite() && (-10.0..=0.0).contains(&v)); + let w = l.value_at(i64::MAX - 1, 0.0); + assert!(w.is_finite() && (-10.0..=0.0).contains(&w)); + } + + #[test] + fn value_at_opposite_sign_extremes_stays_finite() { + let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() }); + l.set_point(0, -3.0e38); + l.set_point(1000, 3.0e38); + let v = l.value_at(500, 0.0); + assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}"); + let w = l.value_at(1, 0.0); + assert!(w.is_finite() && w < -2.9e38, "early value {w}"); + } + + #[test] + fn pan_label_max_does_not_overflow() { + assert_eq!(AutoParam::Pan(255).label(), "pan 256"); + } + + #[test] + fn interpolation_and_steps() { + let mut l = AutomationLane::new(AutoParam::Volume); + assert_eq!(l.value_at(10, -3.0), -3.0); + l.set_point(0, 0.0); + l.set_point(100, -10.0); + assert!((l.value_at(50, 0.0) + 5.0).abs() < 1e-6); + assert_eq!(l.value_at(1000, 0.0), -10.0); + let mut m = AutomationLane::new(AutoParam::Mute); + m.set_point(0, 0.0); + m.set_point(100, 1.0); + assert_eq!(m.value_at(99, 0.0), 0.0); + assert_eq!(m.value_at(100, 0.0), 1.0); + } + + #[test] + fn write_range_keeps_point_right_after_end() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(11, -100.0); + l.write_range(2, 10, -20.0, 0.0); + assert_eq!(l.value_at(11, 0.0), -100.0); + } + + #[test] + fn write_range_preserves_outside() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(1000, 0.0); + l.write_range(200, 400, -20.0, 0.0); + assert_eq!(l.value_at(100, 0.0), 0.0); + assert_eq!(l.value_at(300, 0.0), -20.0); + assert_eq!(l.value_at(600, 0.0), 0.0); + } + + #[test] + fn thin_removes_collinear() { + let mut l = AutomationLane::new(AutoParam::Volume); + for i in 0..=10 { + l.set_point(i * 10, -(i as f32)); + } + assert_eq!(l.thin(0.01), 9); + assert_eq!(l.points.len(), 2); + } + + #[test] + fn thin_keeps_removed_points_within_tolerance() { + for direction in [1.0, -1.0] { + let mut lane = AutomationLane::new(AutoParam::Volume); + for (at, value) in [(0, 0.0), (100, 0.0), (200, 0.0), (300, 0.12), (400, 0.26)] { + lane.set_point(at, value * direction); + } + let original = lane.points.clone(); + let tolerance = 0.1; + assert!(lane.thin(tolerance) > 0); + for point in original { + let error = (lane.value_at(point.at, 0.0) - point.value).abs(); + assert!(error <= tolerance, "at {}: error {error} exceeds {tolerance}", point.at); + } + } + } + + #[test] + fn thin_keeps_mute_changes_in_place() { + for param in [AutoParam::Mute, AutoParam::SendMute(0)] { + let mut lane = AutomationLane::new(param); + for (at, value) in [(0, 0.0), (9000, 1.0), (10000, 1.0)] { + lane.set_point(at, value); + } + lane.thin(0.1); + assert_eq!(lane.value_at(8999, 0.0), 0.0); + assert_eq!(lane.value_at(9000, 0.0), 1.0); + } + } + + #[test] + fn param_ids() { + assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1))); + assert_eq!(AutoParam::parse("plugin:a:threshold"), Some(AutoParam::Plugin { slot: 0, param: "threshold".into() })); + assert_eq!(AutoParam::parse("nope"), None); + assert_eq!(AutomationMode::from_id("touch/latch"), Some(AutomationMode::TouchLatch)); + } + + #[test] + fn set_point_clamps_nan() { + let mut l = AutomationLane::new(AutoParam::Pan(0)); + l.set_point(0, f32::NAN); + l.set_point(1, 5.0); + assert_eq!(l.points[0].value, 0.0); + assert_eq!(l.points[1].value, 1.0); + } +} diff --git a/crates/scraft-format/src/b64.rs b/crates/scraft-format/src/b64.rs new file mode 100644 index 000000000..100a9a08f --- /dev/null +++ b/crates/scraft-format/src/b64.rs @@ -0,0 +1,114 @@ +//! Standard base64 (RFC 4648, `+/` alphabet, `=` padding), used to keep opaque plugin state +//! blobs in the JSON session file. Decoding treats its input as hostile: it rejects bad +//! characters and bad padding, ignores ASCII whitespace, and caps the output size. + +/// Largest blob [`decode`] will produce (64 MiB): bigger session fields are rejected. +pub const MAX_DECODED_BYTES: usize = 64 << 20; + +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encodes bytes as padded standard base64. +pub fn encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3).saturating_mul(4)); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + let sym = |shift: u32| char::from(ALPHABET.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A')); + out.push(sym(18)); + out.push(sym(12)); + out.push(if chunk.len() > 1 { sym(6) } else { '=' }); + out.push(if chunk.len() > 2 { sym(0) } else { '=' }); + } + out +} + +fn value(c: u8) -> Option { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + Some(u32::from(v)) +} + +/// Decodes padded (or unpadded) standard base64. `None` for malformed input or a result larger +/// than [`MAX_DECODED_BYTES`]. +pub fn decode(text: &str) -> Option> { + if text.bytes().filter(|c| !c.is_ascii_whitespace()).count() > MAX_DECODED_BYTES.div_ceil(3).saturating_mul(4) { + return None; + } + let syms: Vec = text.bytes().filter(|c| !c.is_ascii_whitespace()).collect(); + let body = syms.strip_suffix(b"==").or_else(|| syms.strip_suffix(b"=")).unwrap_or(&syms); + if body.contains(&b'=') || (syms.len() != body.len() && !syms.len().is_multiple_of(4)) || body.len() % 4 == 1 { + return None; + } + let decoded_len = body.len() / 4 * 3 + body.len() % 4 * 3 / 4; + if decoded_len > MAX_DECODED_BYTES { + return None; + } + let mut out = Vec::with_capacity(decoded_len); + for chunk in body.chunks(4) { + let mut n = 0u32; + for (i, &c) in chunk.iter().enumerate() { + n |= value(c)? << (18 - 6 * i as u32); + } + let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8]; + out.extend_from_slice(bytes.get(..chunk.len() - 1)?); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc4648_vectors_round_trip() { + for (plain, enc) in + [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + assert_eq!(encode(plain.as_bytes()), enc); + assert_eq!(decode(enc).unwrap(), plain.as_bytes()); + } + let all: Vec = (0..=255).collect(); + assert_eq!(decode(&encode(&all)).unwrap(), all); + assert_eq!(decode("Zm9v\nYmE=").unwrap(), b"fooba", "whitespace is ignored"); + assert_eq!(decode("Zm9vYmE").unwrap(), b"fooba", "padding is optional"); + } + + #[test] + fn hostile_input_is_rejected() { + for bad in ["Z", "Zm9=v", "Zm9v!", "====", "Zg=", "é", "Zg===", "Z==="] { + assert!(decode(bad).is_none(), "{bad}"); + } + } + + #[test] + fn size_cap_counts_the_partial_quartet() { + fn encoded_zero_bytes(len: usize) -> String { + let groups = len / 3; + let mut encoded = "A".repeat(groups * 4); + match len % 3 { + 1 => encoded.push_str("AA=="), + 2 => encoded.push_str("AAA="), + _ => {} + } + encoded + } + + // Build valid base64 directly instead of encoding a 64 MiB allocation on every case. + // This keeps the same decoded-size boundary checks while making the workspace gate much + // faster and using less temporary memory. + let exact = encoded_zero_bytes(MAX_DECODED_BYTES); + assert_eq!(decode(&exact).map(|bytes| bytes.len()), Some(MAX_DECODED_BYTES), "padded boundary"); + assert_eq!(decode(exact.trim_end_matches('=')).map(|bytes| bytes.len()), Some(MAX_DECODED_BYTES), "unpadded boundary"); + + let too_large = encoded_zero_bytes(MAX_DECODED_BYTES + 1); + assert!(decode(&too_large).is_none(), "padded over boundary"); + let spaced = format!("{}\n {}", &too_large[..8], &too_large[8..]); + assert!(decode(&spaced).is_none(), "whitespace over boundary"); + } +} diff --git a/crates/scraft-format/src/bundle.rs b/crates/scraft-format/src/bundle.rs new file mode 100644 index 000000000..52d6d33be --- /dev/null +++ b/crates/scraft-format/src/bundle.rs @@ -0,0 +1,250 @@ +//! The optional `.scraftz` ZIP container for a `.scraft` session and its embedded audio files. + +use crate::{FileFormat, MAX_SESSION_BYTES, SESSION_EXTENSION, Session}; +use craft_audio::{BitDepth, EncodeFormat, EncodeOptions, encode}; +use craft_zip::{Archive, Compression, Limits, Writer}; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; +use std::path::Path; + +/// Extension of a ZIP bundle containing a `.scraft` session and its audio files. +pub const BUNDLE_EXTENSION: &str = "scraftz"; + +/// The limits applied to a bundle in both directions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BundleLimits { + /// Maximum ZIP entries, including directory entries. + pub entries: usize, + /// Maximum combined uncompressed size of entries. + pub bytes: u64, + /// Maximum size of the contained session JSON. + pub session_bytes: u64, +} + +/// SoundCraft's current bundle defaults: 10,000 entries, 1 GiB expanded and 256 MiB per session. +pub const DEFAULT_BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30, session_bytes: MAX_SESSION_BYTES }; + +/// Errors while reading or writing a session bundle. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BundleError { + /// The archive is malformed, exceeds a configured limit, or does not contain a session. + #[error("bundle: {0}")] + Invalid(String), + /// The session JSON cannot be serialized. + #[error("{0}")] + Session(String), + /// The embedded audio cannot be encoded as WAV. + #[error("{0}")] + Audio(String), +} + +/// Write a session and its decoded audio as a deterministic `.scraftz` ZIP bundle. +/// +/// Audio entries use the same float WAV representation as SoundCraft's desktop bundle writer. +/// The returned session has each included source's path rewritten to `Audio Files/.wav`, +/// its format set to `Wav`, and `unsaved` cleared. +pub fn write_bundle(session: &Session, limits: BundleLimits) -> Result<(Vec, Session), BundleError> { + let mut fresh = session.clone(); + let mut zip = Writer::new(); + let mut names = HashSet::new(); + let mut count = 0usize; + let mut total = 0u64; + + for source in &mut fresh.sources { + let Some(audio) = fresh.pool.get(source.id) else { continue }; + let bytes = encode(&audio.buffer, &EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(BitDepth::Float32)) + .map_err(|e| BundleError::Audio(e.to_string()))?; + let base = sanitize_name(&source.name); + let mut target = format!("Audio Files/{base}.wav"); + let mut suffix = 1usize; + while names.contains(&target) && suffix <= 9_999 { + target = format!("Audio Files/{base}_{suffix:02}.wav"); + suffix += 1; + } + account(&mut count, &mut total, bytes.len(), limits)?; + zip.add(&target, &bytes, Compression::Stored).map_err(|e| BundleError::Invalid(e.to_string()))?; + names.insert(target.clone()); + source.path = target; + source.unsaved = false; + source.format = FileFormat::Wav; + } + + let text = fresh.to_json().map_err(|e| BundleError::Session(e.to_string()))?; + check_session_size("the session", text.len() as u64, limits.session_bytes, true)?; + account(&mut count, &mut total, text.len(), limits)?; + let session_name = format!("{}.{}", sanitize_name(&fresh.name), SESSION_EXTENSION); + zip.add(&session_name, text.as_bytes(), Compression::Stored).map_err(|e| BundleError::Invalid(e.to_string()))?; + let bytes = zip.finish().map_err(|e| BundleError::Invalid(e.to_string()))?; + Ok((bytes, fresh)) +} + +/// Read the first root-level `.scraft` and retain other files by their archive path. +/// +/// Duplicate entry names, non-text names and entries whose file data overlaps another entry are +/// refused before payloads are copied. The session byte cap is checked against the ZIP's declared +/// size before decompressing its JSON. +pub fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, HashMap>), BundleError> { + let max_entries = u64::try_from(limits.entries).unwrap_or(u64::MAX); + let mut archive = Archive::open(bytes, Limits { max_entries, max_total_bytes: limits.bytes }).map_err(zip_error)?; + if archive.has_duplicate_names() { + return Err(BundleError::Invalid("files share a name".into())); + } + if archive.has_overlapping_files().map_err(zip_error)? { + return Err(BundleError::Invalid("files share data".into())); + } + let names: Vec = archive.names().map(|n| n.into_owned()).collect(); + if names.len() != archive.len() { + return Err(BundleError::Invalid("an entry name is not text".into())); + } + let mut session = None; + let mut files = HashMap::new(); + for name in names { + if name.ends_with('/') { + continue; + } + let is_session = !name.contains('/') && Path::new(&name).extension().and_then(OsStr::to_str) == Some(SESSION_EXTENSION); + let size = archive.declared_size(&name).ok_or_else(|| BundleError::Invalid(format!("{name}: missing entry")))?; + if is_session && session.is_none() { + check_session_size(&name, size, limits.session_bytes, false).map_err(bundle_context)?; + } + let data = archive.read(&name, limits.bytes).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid(format!("{name}: missing entry")))?; + if is_session && session.is_none() { + session = Some(String::from_utf8(data).map_err(|_| BundleError::Invalid(format!("{name} is not text")))?); + } else { + files.insert(name, data); + } + } + let session = session.ok_or_else(|| BundleError::Invalid("no .scraft session inside".into()))?; + Ok((session, files)) +} + +/// Refuse a session that cannot be opened again under the same byte cap. +pub fn check_session_size(name: &str, len: u64, limit: u64, saving: bool) -> Result<(), BundleError> { + if len <= limit { + return Ok(()); + } + let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") }; + let message = if saving { + format!("{name} is too large to save: over {most}, more than SoundCraft opens") + } else { + format!("{name} is too large to open (over {most})") + }; + Err(BundleError::Session(message)) +} + +fn bundle_context(error: BundleError) -> BundleError { + match error { + BundleError::Session(message) => BundleError::Invalid(message), + other => other, + } +} + +fn account(count: &mut usize, total: &mut u64, len: usize, limits: BundleLimits) -> Result<(), BundleError> { + *count = count.saturating_add(1); + *total = total.saturating_add(u64::try_from(len).unwrap_or(u64::MAX)); + if *count > limits.entries || *total > limits.bytes { + return Err(BundleError::Invalid(format!( + "too large to save (a bundle holds at most {} files and {} MiB, or it can't be opened again)", + limits.entries, + limits.bytes >> 20 + ))); + } + Ok(()) +} + +fn zip_error(error: craft_zip::Error) -> BundleError { + match error { + craft_zip::Error::NotZip(message) => BundleError::Invalid(message), + craft_zip::Error::TooManyEntries { .. } => BundleError::Invalid("too many files".into()), + craft_zip::Error::EntryTooLarge { .. } | craft_zip::Error::BudgetSpent { .. } => BundleError::Invalid("too large".into()), + craft_zip::Error::Entry { name, msg } => BundleError::Invalid(format!("{name}: {msg}")), + other => BundleError::Invalid(other.to_string()), + } +} + +fn sanitize_name(name: &str) -> String { + let text: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); + let text = text.trim().trim_start_matches('.').to_string(); + if text.is_empty() { "untitled".into() } else { text.chars().take(100).collect() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + #[test] + fn bare_session_round_trips_in_a_bundle() { + let session = Session::default(); + let (bytes, fresh) = write_bundle(&session, DEFAULT_BUNDLE_LIMITS).unwrap(); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert_eq!(fresh, session); + assert_eq!(write_bundle(&session, DEFAULT_BUNDLE_LIMITS).unwrap().0, bytes, "same input writes the same ZIP bytes"); + let (text, files) = read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).unwrap(); + assert!(files.is_empty()); + assert_eq!(Session::from_json(&text).unwrap(), session); + } + + #[test] + fn audio_is_stored_as_float_wav_and_the_session_path_is_rewritten() { + use crate::{FileFormat, Source, SourceAudio, SourceId}; + use craft_audio::AudioBuffer; + use std::sync::Arc; + + let id = SourceId(1); + let samples = [-2.0, -0.25, 0.5, 1.5]; + let mut audio = AudioBuffer::new(48_000, 1, samples.len()); + audio.channels[0].copy_from_slice(&samples); + let mut session = Session::default(); + session.sources.push(Source { + id, + name: "Lead / take?".into(), + path: "elsewhere.wav".into(), + channels: 1, + frames: samples.len() as u64, + sample_rate: 48_000, + format: FileFormat::Other, + time_reference: 0, + unsaved: true, + }); + session.pool.insert(id, Arc::new(SourceAudio::new(audio))); + + let (bytes, fresh) = write_bundle(&session, DEFAULT_BUNDLE_LIMITS).unwrap(); + let source = fresh.sources.first().unwrap(); + assert_eq!(source.path, "Audio Files/Lead _ take_.wav"); + assert_eq!(source.format, FileFormat::Wav); + assert!(!source.unsaved); + + let (text, files) = read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).unwrap(); + let parsed = Session::from_json(&text).unwrap(); + assert_eq!(parsed.sources[0].path, source.path); + let wav = files.get(&source.path).unwrap(); + let (_, decoded) = craft_audio::decode(wav, Some("wav")).unwrap(); + assert_eq!(decoded.channels[0], samples); + } + + #[test] + fn session_size_is_checked_in_both_directions() { + let too_big = check_session_size("a session", 11, 10, true).unwrap_err(); + assert_eq!(too_big.to_string(), "a session is too large to save: over 10 bytes, more than SoundCraft opens"); + let too_big = check_session_size("a session", 11, 10, false).unwrap_err(); + assert_eq!(too_big.to_string(), "a session is too large to open (over 10 bytes)"); + } + + #[test] + fn a_bundle_without_a_session_is_refused() { + let mut zip = Writer::new(); + zip.add("Audio Files/a.wav", b"audio", Compression::Stored).unwrap(); + let bytes = zip.finish().unwrap(); + assert_eq!(read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).unwrap_err().to_string(), "bundle: no .scraft session inside"); + } + + #[test] + fn every_truncation_of_a_bundle_is_an_error_not_a_panic() { + let (bytes, _) = write_bundle(&Session::default(), DEFAULT_BUNDLE_LIMITS).unwrap(); + for cut in 0..bytes.len() { + assert!(read_bundle(&bytes[..cut], DEFAULT_BUNDLE_LIMITS).is_err(), "accepted truncation at {cut}"); + } + assert!(read_bundle(&bytes, DEFAULT_BUNDLE_LIMITS).is_ok()); + } +} diff --git a/crates/scraft-format/src/clip.rs b/crates/scraft-format/src/clip.rs new file mode 100644 index 000000000..28e60b6d9 --- /dev/null +++ b/crates/scraft-format/src/clip.rs @@ -0,0 +1,544 @@ +//! Clips: windows onto audio sources or MIDI sequences, placed on a playlist. + +use crate::soundcraft_midi::Sequence; +use crate::soundcraft_time::{MAX_POSITION, Range, Samples}; +use crate::{ClipId, MAX_NAME_CHARS, SourceId, truncate_chars}; + +/// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum FadeShape { + Linear, + #[default] + EqualPower, + SCurve, + Exponential, + Logarithmic, +} + +impl FadeShape { + pub const ALL: [FadeShape; 5] = [FadeShape::Linear, FadeShape::EqualPower, FadeShape::SCurve, FadeShape::Exponential, FadeShape::Logarithmic]; + + /// Fade-in gain at `t` ∈ [0,1] (fade-outs use `gain(1 - t)`). + pub fn gain(self, t: f32) -> f32 { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + match self { + FadeShape::Linear => t, + FadeShape::EqualPower => (t * std::f32::consts::FRAC_PI_2).sin(), + FadeShape::SCurve => 0.5 - 0.5 * (t * std::f32::consts::PI).cos(), + FadeShape::Exponential => t * t * t, + FadeShape::Logarithmic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), + } + } + + pub fn label(self) -> &'static str { + match self { + FadeShape::Linear => "Linear", + FadeShape::EqualPower => "Equal Power", + FadeShape::SCurve => "S-Curve", + FadeShape::Exponential => "Exponential", + FadeShape::Logarithmic => "Logarithmic", + } + } + + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase().replace(['-', '_', ' '], ""); + FadeShape::ALL.into_iter().find(|f| f.label().to_ascii_lowercase().replace(['-', ' '], "") == s) + } +} + +/// A fade at a clip boundary, `len` samples long. +#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub struct Fade { + pub len: Samples, + pub shape: FadeShape, + /// Continuous bend (-1..1); zero preserves the original shape exactly. + #[serde(default)] + pub curvature: f32, +} + +impl Fade { + /// The envelope used by both rendering and audio, with fixed zero/unity endpoints. + pub fn gain(self, t: f32) -> f32 { + let base = self.shape.gain(t); + let curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + if curvature == 0.0 || base <= 0.0 || base >= 1.0 { + return base; + } + let weighted = base * (curvature * 4.0).exp2(); + weighted / (weighted + 1.0 - base) + } + + /// Invert the bend at the midpoint so a vertical drag follows the pointer. + pub fn curvature_for_midpoint_gain(self, gain: f32) -> f32 { + let gain = if gain.is_finite() { gain.clamp(0.001, 0.999) } else { self.gain(0.5) }; + let base = self.shape.gain(0.5); + (((gain / (1.0 - gain)).log2() - (base / (1.0 - base)).log2()) / 4.0).clamp(-1.0, 1.0) + } + + pub fn sanitize(&mut self) { + self.curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + } +} + +/// What a clip plays. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ClipContent { + /// Audio from `source`, starting `offset` samples into the file. + Audio { source: SourceId, offset: Samples }, + /// MIDI notes, with ticks relative to the clip start. + Midi { sequence: Sequence }, + /// Picture from the movie `source` ([`crate::VideoSource`], `Session::videos`), starting + /// `offset` samples into the movie. Video clips carry no audio. + Video { source: SourceId, offset: Samples }, +} + +/// A clip on a playlist. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Clip { + pub id: ClipId, + pub name: String, + /// Timeline start (samples). + pub start: Samples, + /// Length (samples, > 0). + pub length: Samples, + pub content: ClipContent, + /// Static clip gain in dB. + #[serde(default)] + pub gain_db: f32, + /// Clip gain envelope breakpoints: (offset from clip start, dB). Empty = flat `gain_db`. + #[serde(default)] + pub gain_env: Vec<(Samples, f32)>, + #[serde(default)] + pub fade_in: Fade, + #[serde(default)] + pub fade_out: Fade, + #[serde(default)] + pub muted: bool, + #[serde(default)] + pub edit_locked: bool, + #[serde(default)] + pub time_locked: bool, + #[serde(default)] + pub rating: u8, + /// Sync point offset from clip start. + #[serde(default)] + pub sync_point: Samples, + /// Optional colour override (RGB). + #[serde(default)] + pub color: Option<[u8; 3]>, + /// The key in [`crate::Session::auto_names`] of the names that editing gives the clips it + /// separates from this one (Clip List › Auto Rename). `None` keeps the clip's name. + #[serde(default)] + pub auto_name: Option, + /// Elastic / Vari-speed ratio (1.0 = none) — applied by rendered processing. + #[serde(default = "one")] + pub stretch: f64, + /// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews). + #[serde(default)] + pub group: Option, + /// Marks the clips made by `clip.loop` (the origin and its copies share one id), so + /// `clip.unloop` still recognises the loop after the source's content is edited. + #[serde(default)] + pub loop_id: Option, +} + +fn one() -> f64 { + 1.0 +} + +impl Clip { + pub fn audio(id: ClipId, name: impl Into, source: SourceId, offset: Samples, start: Samples, length: Samples) -> Clip { + Clip { + id, + name: name.into(), + start, + length: length.max(1), + content: ClipContent::Audio { source, offset }, + gain_db: 0.0, + gain_env: Vec::new(), + fade_in: Fade::default(), + fade_out: Fade::default(), + muted: false, + edit_locked: false, + time_locked: false, + rating: 0, + sync_point: 0, + color: None, + auto_name: None, + stretch: 1.0, + group: None, + loop_id: None, + } + } + + pub fn midi(id: ClipId, name: impl Into, start: Samples, length: Samples, sequence: Sequence) -> Clip { + let mut c = Clip::audio(id, name, SourceId(0), 0, start, length); + c.content = ClipContent::Midi { sequence }; + c + } + + /// A clip showing the movie `source` from `offset` samples into it. + pub fn video(id: ClipId, name: impl Into, source: SourceId, offset: Samples, start: Samples, length: Samples) -> Clip { + let mut c = Clip::audio(id, name, SourceId(0), 0, start, length); + c.content = ClipContent::Video { source, offset }; + c + } + + pub fn end(&self) -> Samples { + self.start.saturating_add(self.length) + } + + pub fn range(&self) -> Range { + Range { start: self.start, end: self.end() } + } + + pub fn is_audio(&self) -> bool { + matches!(self.content, ClipContent::Audio { .. }) + } + + pub fn is_video(&self) -> bool { + matches!(self.content, ClipContent::Video { .. }) + } + + /// The audio source this clip plays (None for MIDI and video clips). + pub fn source(&self) -> Option { + match self.content { + ClipContent::Audio { source, .. } => Some(source), + ClipContent::Midi { .. } | ClipContent::Video { .. } => None, + } + } + + /// The movie this clip shows (video clips only). + pub fn video_source(&self) -> Option { + match self.content { + ClipContent::Video { source, .. } => Some(source), + ClipContent::Audio { .. } | ClipContent::Midi { .. } => None, + } + } + + /// Offset into the audio file or movie (0 for MIDI). + pub fn source_offset(&self) -> Samples { + match self.content { + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => offset, + ClipContent::Midi { .. } => 0, + } + } + + /// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades. + pub fn gain_at(&self, rel: Samples) -> f32 { + self.gain_at_with_fades(rel, self.fade_in, self.fade_out) + } + + /// Evaluate temporary fades while retaining clip gain and its envelope. + /// UI previews can use this without modifying the clip or rebuilding media. + pub fn gain_at_with_fades(&self, rel: Samples, fade_in: Fade, fade_out: Fade) -> f32 { + let mut db = self.gain_db; + if !self.gain_env.is_empty() { + db += env_value(&self.gain_env, rel); + } + let mut g = db_to_gain(db); + if fade_in.len > 0 && rel < fade_in.len { + g *= fade_in.gain(rel as f32 / fade_in.len as f32); + } + let from_end = self.length.saturating_sub(rel); + if fade_out.len > 0 && from_end <= fade_out.len { + g *= fade_out.gain(from_end as f32 / fade_out.len as f32); + } + g + } + + /// Gain envelope value in dB at `rel` samples from the clip start (0 without an envelope). + pub fn env_db_at(&self, rel: Samples) -> f32 { + env_value(&self.gain_env, rel) + } + + /// Move the start edge to `new_start` keeping material anchored (trim start). + pub fn trim_start_to(&mut self, new_start: Samples) { + // #907: these are public APIs, so they have to survive arguments a command would never + // pass (commands clamp to `MAX_POSITION` first). `Samples` is `i64`, so an extreme + // `new_start` against an extreme `start` wrapped in release and panicked in debug. + // Never so far back that the length would pass `MAX_POSITION`: trimming the start keeps the end. + let end = self.end(); + let new_start = new_start.max(end.saturating_sub(MAX_POSITION)).clamp(-MAX_POSITION, MAX_POSITION).min(end.saturating_sub(1)); + let delta = new_start.saturating_sub(self.start); + // The mixer plays a stretched audio clip from `offset + rel / stretch`. + let stretch = if self.stretch.is_finite() && self.stretch > 0.0 { self.stretch } else { 1.0 }; + match &mut self.content { + ClipContent::Audio { offset, .. } if (stretch - 1.0).abs() >= 1e-9 => { + *offset = offset.saturating_add((delta as f64 / stretch).round() as Samples); + } + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => { + *offset = offset.saturating_add(delta); + } + ClipContent::Midi { .. } => {} + } + // MIDI note ticks are re-anchored by the engine, which knows the tempo map. + self.length = self.length.saturating_sub(delta).clamp(1, MAX_POSITION); + self.start = new_start; + if self.sync_point != 0 { + self.sync_point = self.sync_point.saturating_sub(delta); + } + self.drop_stray_sync(); + self.clamp_fades(); + // Keep the gain at the cut when earlier breakpoints are dropped. + if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) { + self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta))); + } + self.gain_env.retain(|&(t, _)| t >= delta); + self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta)); + } + + /// Move the end edge to `new_end`. + pub fn trim_end_to(&mut self, new_end: Samples) { + // #907: saturating, like `trim_start_to`. + let new_end = new_end.clamp(-MAX_POSITION, MAX_POSITION).max(self.start.saturating_add(1)); + self.length = new_end.saturating_sub(self.start).clamp(1, MAX_POSITION); + self.drop_stray_sync(); + self.clamp_fades(); + } + + /// Keep both fades within the clip. + pub fn clamp_fades(&mut self) { + self.fade_in.len = self.fade_in.len.clamp(0, self.length); + self.fade_out.len = self.fade_out.len.clamp(0, self.length); + } + + /// A sync point (0 means none) that a trim left outside the clip no longer marks anything. + fn drop_stray_sync(&mut self) { + if !(0..self.length).contains(&self.sync_point) { + self.sync_point = 0; + } + } + + /// Repair a clip read from a file: positions and lengths within ±[`MAX_POSITION`], as commands + /// keep theirs, fades within the clip, a name that can be drawn, and a usable gain and stretch. + pub fn sanitize(&mut self) { + bound(&mut self.start); + self.length = self.length.clamp(1, MAX_POSITION); + bound(&mut self.sync_point); + self.gain_env.iter_mut().for_each(|(at, _)| bound(at)); + match &mut self.content { + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => bound(offset), + ClipContent::Midi { sequence } => bound_sequence(sequence), + } + self.clamp_fades(); + self.fade_in.sanitize(); + self.fade_out.sanitize(); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + if !self.gain_db.is_finite() { + self.gain_db = 0.0; + } + if !self.stretch.is_finite() || self.stretch <= 0.0 { + self.stretch = 1.0; + } + } +} + +pub(crate) fn bound(at: &mut Samples) { + *at = (*at).clamp(-MAX_POSITION, MAX_POSITION); +} + +pub(crate) fn bound_sequence(s: &mut Sequence) { + for n in &mut s.notes { + bound(&mut n.start); + n.length = n.length.clamp(1, MAX_POSITION); + } + s.ctrls.iter_mut().for_each(|c| bound(&mut c.tick)); +} + +fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { + let mut prev: Option<(Samples, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => { + let elapsed = i128::from(at).saturating_sub(i128::from(pt)); + let span = i128::from(t).saturating_sub(i128::from(pt)); + pv + (v - pv) * (elapsed as f32 / span as f32) + } + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + +pub(crate) fn db_to_gain(db: f32) -> f32 { + if db.is_nan() || db <= -144.0 { 0.0 } else { 10f32.powf(db.min(48.0) / 20.0) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fade_shapes_hit_endpoints() { + for s in FadeShape::ALL { + assert!(s.gain(0.0).abs() < 1e-6, "{s:?}"); + assert!((s.gain(1.0) - 1.0).abs() < 1e-6, "{s:?}"); + assert_eq!(s.gain(f32::NAN), 0.0); + assert_eq!(FadeShape::from_id(s.label()), Some(s)); + } + } + + #[test] + fn fade_curvature_is_bounded_monotonic_and_inverts_at_the_midpoint() { + for shape in FadeShape::ALL { + let original = Fade { len: 1000, shape, curvature: 0.0 }; + for i in 0..=128 { + let t = i as f32 / 128.0; + assert_eq!(original.gain(t), shape.gain(t), "legacy curves must be unchanged"); + } + for curvature in [-1.0, -0.5, 0.0, 0.5, 1.0] { + let fade = Fade { curvature, ..original }; + assert_eq!(fade.gain(0.0), 0.0); + assert_eq!(fade.gain(1.0), 1.0); + let mut previous = 0.0; + for i in 0..=128 { + let gain = fade.gain(i as f32 / 128.0); + assert!(gain.is_finite() && (0.0..=1.0).contains(&gain)); + assert!(gain >= previous, "{shape:?}, {curvature}"); + previous = gain; + } + assert!((fade.curvature_for_midpoint_gain(fade.gain(0.5)) - curvature).abs() < 1e-5); + } + assert!((Fade { curvature: 0.5, ..original }).gain(0.5) > original.gain(0.5)); + assert!((Fade { curvature: -0.5, ..original }).gain(0.5) < original.gain(0.5)); + } + } + + #[test] + fn fade_curvature_loads_legacy_data_and_sanitizes_hostile_values() { + let fade: Fade = serde_json::from_str(r#"{"len":100,"shape":"SCurve"}"#).unwrap(); + assert_eq!(fade.curvature, 0.0); + for curvature in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -10.0, 10.0] { + let mut fade = Fade { curvature, ..fade }; + assert!(fade.gain(0.5).is_finite()); + fade.sanitize(); + assert!(fade.curvature.is_finite() && (-1.0..=1.0).contains(&fade.curvature)); + let roundtrip: Fade = serde_json::from_str(&serde_json::to_string(&fade).unwrap()).unwrap(); + assert_eq!(roundtrip, fade); + } + } + + #[test] + fn temporary_fades_keep_clip_gain_and_envelope_without_mutating_the_clip() { + let mut clip = Clip::audio(ClipId(1), "preview", SourceId(1), 0, 0, 1000); + clip.gain_db = -6.0; + clip.gain_env = vec![(0, 0.0), (1000, 6.0)]; + clip.fade_in = Fade { len: 100, shape: FadeShape::SCurve, curvature: 0.25 }; + clip.fade_out = Fade { len: 100, shape: FadeShape::Logarithmic, curvature: -0.1 }; + let before = clip.clone(); + let fade_in = Fade { len: 1000, shape: FadeShape::Linear, curvature: 0.5 }; + let fade_out = Fade { len: 1000, shape: FadeShape::Linear, curvature: -0.5 }; + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + let mut committed = clip.clone(); + committed.fade_in = fade_in; + committed.fade_out = fade_out; + for rel in [0, 50, 500, 999, 1000] { + assert_eq!(clip.gain_at_with_fades(rel, fade_in, fade_out), committed.gain_at(rel)); + } + for rel in [i64::MIN, i64::MAX] { + assert!(clip.gain_at(rel).is_finite()); + assert!(clip.gain_at_with_fades(rel, fade_in, fade_out).is_finite()); + } + assert_eq!(clip, before); + clip.gain_env = vec![(i64::MIN, 0.0), (i64::MAX, 6.0)]; + assert!((clip.gain_at(500) - db_to_gain(-3.0)).abs() < 1e-6); + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + } + + #[test] + fn gain_with_fades() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); + c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + assert!(c.gain_at(0).abs() < 1e-6); + assert!((c.gain_at(50) - 0.5).abs() < 1e-6); + assert!((c.gain_at(500) - 1.0).abs() < 1e-6); + assert!((c.gain_at(950) - 0.5).abs() < 1e-6); + } + + #[test] + fn gain_envelope_interpolates() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 1000); + c.gain_env = vec![(0, 0.0), (1000, -20.0)]; + assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4); + } + + #[test] + fn trim_start_keeps_gain_envelope_at_cut() { + for (env, cut, expected) in [ + (vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]), + (vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![], 500, vec![]), + (vec![(100, -3.0)], 0, vec![(100, -3.0)]), + (vec![(100, -3.0)], -100, vec![(200, -3.0)]), + ] { + let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000); + clip.gain_env = env; + let gain_at_cut = clip.gain_at(cut); + clip.trim_start_to(cut); + assert_eq!(clip.gain_env, expected); + if cut > 0 { + assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6); + } + } + } + + #[test] + fn trims_keep_material_anchored() { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000); + c.trim_start_to(300); + assert_eq!((c.start, c.length, c.source_offset()), (300, 800, 1200)); + c.trim_end_to(500); + assert_eq!(c.length, 200); + c.trim_end_to(-50); + assert_eq!(c.length, 1); + // Regression: trimming the start must not leave a fade-out longer than the clip. + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 1000); + c.fade_out = Fade { len: 800, shape: FadeShape::Linear, curvature: 0.0 }; + c.trim_start_to(900); + assert!(c.fade_out.len <= c.length); + } + + /// #907: `trim_start_to` / `trim_end_to` are public APIs, so they have to survive arguments a + /// command would never pass. `Samples` is `i64`, so an extreme `new_start` against an extreme + /// `start` wrapped in release and panicked in debug. Every position stays within + /// `±MAX_POSITION` and every length within `1..=MAX_POSITION`. + #[test] + fn trims_saturate_on_extreme_arguments() { + for &new_start in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] { + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, MAX_POSITION / 2, 1000); + c.trim_start_to(new_start); + assert!((-(MAX_POSITION as i128)..=(MAX_POSITION as i128)).contains(&(c.start as i128)), "start {new_start}: {}", c.start); + assert!((1..=MAX_POSITION).contains(&c.length), "start {new_start}: length {}", c.length); + assert_eq!(c.end(), MAX_POSITION / 2 + 1000, "start {new_start} moved the end"); + } + for &new_end in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] { + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 1000, -(MAX_POSITION / 2), 1000); + c.trim_end_to(new_end); + assert!((1..=MAX_POSITION).contains(&c.length), "end {new_end}: length {}", c.length); + assert!(c.end() - c.start == c.length, "end {new_end}: start {} end {}", c.start, c.end()); + } + } + + #[test] + fn trim_start_of_stretched_audio_advances_offset_by_source_time() { + // The mixer plays source `offset + rel / stretch`, so a split at timeline 1000 of a 2x clip + // must start the right half at source sample 500. + let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 2000); + c.stretch = 2.0; + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 500); + let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 2000); + c.stretch = 0.5; + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 2000); + let mut c = Clip::audio(ClipId(3), "c", SourceId(1), 0, 0, 2000); + c.trim_start_to(1000); + assert_eq!(c.source_offset(), 1000); + } +} diff --git a/crates/scraft-format/src/ids.rs b/crates/scraft-format/src/ids.rs new file mode 100644 index 000000000..441ced1ae --- /dev/null +++ b/crates/scraft-format/src/ids.rs @@ -0,0 +1,21 @@ +//! Typed ids. All ids come from one session-wide counter so they never collide across kinds. + +macro_rules! id_type { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + pub struct $name(pub u64); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +id_type!(TrackId); +id_type!(ClipId); +id_type!(SourceId); +id_type!(BusId); +id_type!(GroupId); +id_type!(MarkerId); diff --git a/crates/scraft-format/src/lib.rs b/crates/scraft-format/src/lib.rs new file mode 100644 index 000000000..d26bca759 --- /dev/null +++ b/crates/scraft-format/src/lib.rs @@ -0,0 +1,53 @@ +//! The SoundCraft session document. +//! +//! A [`Session`] is pure data: tracks with playlists of clips, the mixer (inserts, sends, routing, +//! automation), tempo/meter, memory locations and groups. Audio sample data lives in +//! [`SourcePool`] behind `Arc`s, so cloning a session for undo is cheap. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod automation; +pub mod b64; +pub mod bundle; +mod clip; +mod ids; +mod markers; +mod mixer; +mod session; +mod source; +mod track; + +pub use craft_midi as soundcraft_midi; +pub use craft_time::soundcraft as soundcraft_time; + +pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint}; +pub use bundle::{BUNDLE_EXTENSION, BundleError, BundleLimits, DEFAULT_BUNDLE_LIMITS, check_session_size, read_bundle, write_bundle}; +pub use clip::{Clip, ClipContent, Fade, FadeShape}; +pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId}; +pub use markers::{Group, MarkerKind, MemoryLocation}; +pub use mixer::{ + Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db, +}; +pub use session::{ + AutoName, BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, + Tool, TrackView, ZoomState, ZoomToggle, truncate_chars, +}; +pub use source::{FileFormat, Source, SourceAudio, SourcePool, VideoSource}; +pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; + +pub use soundcraft_time as time; + +/// Largest `.scraft` session opened or written by SoundCraft. +pub const MAX_SESSION_BYTES: u64 = 256 << 20; + +/// Read a bare `.scraft` file after enforcing the caller's byte limit. +pub fn read_session_bytes(name: &str, bytes: &[u8], limit: u64) -> Result { + check_session_size(name, u64::try_from(bytes.len()).unwrap_or(u64::MAX), limit, false)?; + String::from_utf8(bytes.to_vec()).map_err(|_| BundleError::Session(format!("{name}: not a SoundCraft session"))) +} + +/// Serialize a `.scraft` session and refuse output larger than the caller's read limit. +pub fn write_session(session: &Session, limit: u64) -> Result { + let text = session.to_json().map_err(|error| BundleError::Session(error.to_string()))?; + check_session_size("the session", u64::try_from(text.len()).unwrap_or(u64::MAX), limit, true)?; + Ok(text) +} diff --git a/crates/scraft-format/src/markers.rs b/crates/scraft-format/src/markers.rs new file mode 100644 index 000000000..eaa517dd2 --- /dev/null +++ b/crates/scraft-format/src/markers.rs @@ -0,0 +1,58 @@ +//! Memory locations (markers and stored selections) and mix/edit groups. + +use crate::soundcraft_time::Samples; +use crate::{GroupId, MarkerId, TrackId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum MarkerKind { + /// A point marker on a ruler. + #[default] + Marker, + /// A stored selection range. + Selection, + /// A location that only recalls view settings. + None, +} + +/// A memory location (Window › Memory Locations). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct MemoryLocation { + pub id: MarkerId, + /// User-visible number (1-based, unique). + pub number: u32, + pub name: String, + pub kind: MarkerKind, + pub start: Samples, + pub end: Samples, + #[serde(default)] + pub comments: String, + #[serde(default)] + pub color: Option<[u8; 3]>, + /// Marker ruler lane 1..5. + #[serde(default = "one")] + pub ruler: u8, + /// Track-lane marker on this track, if any. + #[serde(default)] + pub track: Option, +} + +fn one() -> u8 { + 1 +} + +/// A track group (Track › Group…). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Group { + pub id: GroupId, + pub name: String, + /// Group letter id shown in the Groups list ("a", "b", …). + pub letter: char, + pub members: Vec, + pub edit: bool, + pub mix: bool, + pub active: bool, + pub color: [u8; 3], + /// Shared attributes linked by the group (volume, mute, solo, rec, etc.). + #[serde(default)] + pub attributes: Vec, +} diff --git a/crates/scraft-format/src/mixer.rs b/crates/scraft-format/src/mixer.rs new file mode 100644 index 000000000..efdaf2d17 --- /dev/null +++ b/crates/scraft-format/src/mixer.rs @@ -0,0 +1,714 @@ +//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo. + +use crate::{AutomationMode, BusId, SourceId}; +use std::collections::BTreeMap; + +pub const INSERT_SLOTS: usize = 10; +pub const SEND_SLOTS: usize = 10; + +/// Channel formats (track widths). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum ChannelFormat { + #[default] + Mono, + Stereo, + Lcr, + Quad, + Lcrs, + Surround50, + Surround51, + Surround60, + Surround61, + Sdds70, + Sdds71, + Surround70, + Surround71, + Atmos702, + Atmos712, + Atmos502, + Atmos512, + Atmos504, + Atmos514, + Atmos704, + Atmos714, + Atmos706, + Atmos716, + Atmos904, + Atmos914, + Atmos906, + Atmos916, + /// Ambisonics of order 1..=7 ((n+1)² channels). + Ambisonics(u8), +} + +impl ChannelFormat { + pub const ALL: [ChannelFormat; 34] = [ + ChannelFormat::Mono, + ChannelFormat::Stereo, + ChannelFormat::Lcr, + ChannelFormat::Quad, + ChannelFormat::Lcrs, + ChannelFormat::Surround50, + ChannelFormat::Surround51, + ChannelFormat::Surround60, + ChannelFormat::Surround61, + ChannelFormat::Sdds70, + ChannelFormat::Sdds71, + ChannelFormat::Surround70, + ChannelFormat::Surround71, + ChannelFormat::Atmos702, + ChannelFormat::Atmos712, + ChannelFormat::Ambisonics(1), + ChannelFormat::Ambisonics(2), + ChannelFormat::Ambisonics(3), + ChannelFormat::Ambisonics(4), + ChannelFormat::Ambisonics(5), + ChannelFormat::Ambisonics(6), + ChannelFormat::Ambisonics(7), + ChannelFormat::Atmos502, + ChannelFormat::Atmos512, + ChannelFormat::Atmos504, + ChannelFormat::Atmos514, + ChannelFormat::Atmos704, + ChannelFormat::Atmos714, + ChannelFormat::Atmos706, + ChannelFormat::Atmos716, + ChannelFormat::Atmos904, + ChannelFormat::Atmos914, + ChannelFormat::Atmos906, + ChannelFormat::Atmos916, + ]; + pub fn channels(self) -> usize { + match self { + ChannelFormat::Mono => 1, + ChannelFormat::Stereo => 2, + ChannelFormat::Lcr => 3, + ChannelFormat::Quad | ChannelFormat::Lcrs => 4, + ChannelFormat::Surround50 => 5, + ChannelFormat::Surround51 | ChannelFormat::Surround60 => 6, + ChannelFormat::Surround61 | ChannelFormat::Sdds70 | ChannelFormat::Surround70 | ChannelFormat::Atmos502 => 7, + ChannelFormat::Sdds71 | ChannelFormat::Surround71 | ChannelFormat::Atmos512 => 8, + ChannelFormat::Atmos702 | ChannelFormat::Atmos504 => 9, + ChannelFormat::Atmos712 | ChannelFormat::Atmos514 => 10, + ChannelFormat::Atmos704 => 11, + ChannelFormat::Atmos714 => 12, + ChannelFormat::Atmos706 | ChannelFormat::Atmos904 => 13, + ChannelFormat::Atmos716 | ChannelFormat::Atmos914 => 14, + ChannelFormat::Atmos906 => 15, + ChannelFormat::Atmos916 => 16, + ChannelFormat::Ambisonics(n) => { + let n = usize::from(n.clamp(1, 7)) + 1; + n * n + } + } + } + pub fn label(self) -> &'static str { + match self { + ChannelFormat::Mono => "Mono", + ChannelFormat::Stereo => "Stereo", + ChannelFormat::Lcr => "LCR", + ChannelFormat::Quad => "Quad", + ChannelFormat::Lcrs => "LCRS", + ChannelFormat::Surround50 => "5.0", + ChannelFormat::Surround51 => "5.1", + ChannelFormat::Surround60 => "6.0", + ChannelFormat::Surround61 => "6.1", + ChannelFormat::Sdds70 => "7.0 SDDS", + ChannelFormat::Sdds71 => "7.1 SDDS", + ChannelFormat::Surround70 => "7.0", + ChannelFormat::Surround71 => "7.1", + ChannelFormat::Atmos702 => "7.0.2", + ChannelFormat::Atmos712 => "7.1.2", + ChannelFormat::Atmos502 => "5.0.2", + ChannelFormat::Atmos512 => "5.1.2", + ChannelFormat::Atmos504 => "5.0.4", + ChannelFormat::Atmos514 => "5.1.4", + ChannelFormat::Atmos704 => "7.0.4", + ChannelFormat::Atmos714 => "7.1.4", + ChannelFormat::Atmos706 => "7.0.6", + ChannelFormat::Atmos716 => "7.1.6", + ChannelFormat::Atmos904 => "9.0.4", + ChannelFormat::Atmos914 => "9.1.4", + ChannelFormat::Atmos906 => "9.0.6", + ChannelFormat::Atmos916 => "9.1.6", + ChannelFormat::Ambisonics(1) => "1st Order Ambisonics", + ChannelFormat::Ambisonics(2) => "2nd Order Ambisonics", + ChannelFormat::Ambisonics(3) => "3rd Order Ambisonics", + ChannelFormat::Ambisonics(4) => "4th Order Ambisonics", + ChannelFormat::Ambisonics(5) => "5th Order Ambisonics", + ChannelFormat::Ambisonics(6) => "6th Order Ambisonics", + ChannelFormat::Ambisonics(_) => "7th Order Ambisonics", + } + } + pub fn from_id(s: &str) -> Option { + ChannelFormat::ALL.into_iter().find(|f| f.label().eq_ignore_ascii_case(s.trim())) + } + /// Speakers in channel order, or empty for Ambisonics (ACN order, SN3D). + /// + /// Channel order is SMPTE / WAV order: speakers with a WAVE_FORMAT_EXTENSIBLE mask bit come in + /// ascending bit order (L R C LFE Ls Rs Lc Rc Cs Lss Rss Ltf Rtf Ltr Rtr; 7.x puts the rear + /// surrounds Lrs/Rrs on the "back" bits before the sides Lss/Rss), followed by speakers WAV + /// cannot name (the top-middle pair of .6 layouts). E.g. 5.1 = L R C LFE Ls Rs, + /// 7.1 = L R C LFE Lrs Rrs Lss Rss, 7.1.4 = L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr. + pub fn speakers(self) -> &'static [Speaker] { + use Speaker::*; + match self { + ChannelFormat::Mono => &[C], + ChannelFormat::Stereo => &[L, R], + ChannelFormat::Lcr => &[L, R, C], + ChannelFormat::Quad => &[L, R, Ls, Rs], + ChannelFormat::Lcrs => &[L, R, C, Cs], + ChannelFormat::Surround50 => &[L, R, C, Ls, Rs], + ChannelFormat::Surround51 => &[L, R, C, Lfe, Ls, Rs], + ChannelFormat::Surround60 => &[L, R, C, Ls, Rs, Cs], + ChannelFormat::Surround61 => &[L, R, C, Lfe, Ls, Rs, Cs], + ChannelFormat::Sdds70 => &[L, R, C, Ls, Rs, Lc, Rc], + ChannelFormat::Sdds71 => &[L, R, C, Lfe, Ls, Rs, Lc, Rc], + ChannelFormat::Surround70 => &[L, R, C, Lrs, Rrs, Lss, Rss], + ChannelFormat::Surround71 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss], + ChannelFormat::Atmos702 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltm, Rtm], + ChannelFormat::Atmos712 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltm, Rtm], + ChannelFormat::Atmos502 => &[L, R, C, Ls, Rs, Ltm, Rtm], + ChannelFormat::Atmos512 => &[L, R, C, Lfe, Ls, Rs, Ltm, Rtm], + ChannelFormat::Atmos504 => &[L, R, C, Ls, Rs, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos514 => &[L, R, C, Lfe, Ls, Rs, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos704 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos714 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos706 => &[L, R, C, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos716 => &[L, R, C, Lfe, Lrs, Rrs, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos904 => &[L, R, C, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos914 => &[L, R, C, Lfe, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr], + ChannelFormat::Atmos906 => &[L, R, C, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Atmos916 => &[L, R, C, Lfe, Lrs, Rrs, Lw, Rw, Lss, Rss, Ltf, Rtf, Ltr, Rtr, Ltm, Rtm], + ChannelFormat::Ambisonics(_) => &[], + } + } + pub fn is_ambisonic(self) -> bool { + matches!(self, ChannelFormat::Ambisonics(_)) + } + pub fn has_lfe(self) -> bool { + self.speakers().contains(&Speaker::Lfe) + } + pub fn has_height(self) -> bool { + self.speakers().iter().any(|s| s.is_height()) + } + /// Short per-channel labels for meters (`W Y Z X 5…` for Ambisonics). + pub fn channel_label(self, ch: usize) -> String { + match self.speakers().get(ch) { + Some(s) => s.label().to_string(), + None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), ToString::to_string), + None => format!("{}", ch + 1), + } + } + /// WAVE_FORMAT_EXTENSIBLE channel mask (0 = no positions, e.g. Ambisonics). Speakers that WAV + /// cannot name (top middle) borrow the top-front bits when the layout has no top fronts. + pub fn channel_mask(self) -> u32 { + let sp = self.speakers(); + let has_tf = sp.contains(&Speaker::Ltf); + sp.iter().fold(0u32, |m, s| match s { + Speaker::Ltm if !has_tf => m | 0x1000, + Speaker::Rtm if !has_tf => m | 0x4000, + s => m | s.wave_bit(), + }) + } + /// Best format for a channel count. + pub fn for_channels(n: usize) -> ChannelFormat { + [ + ChannelFormat::Mono, + ChannelFormat::Stereo, + ChannelFormat::Lcr, + ChannelFormat::Quad, + ChannelFormat::Surround50, + ChannelFormat::Surround51, + ChannelFormat::Surround70, + ChannelFormat::Surround71, + ] + .into_iter() + .find(|f| f.channels() == n) + .unwrap_or(if n <= 1 { ChannelFormat::Mono } else { ChannelFormat::Stereo }) + } +} + +/// A loudspeaker position in a channel format. +/// +/// Angles follow ITU-R BS.775 / Dolby conventions: azimuth in degrees, 0 = front centre, positive +/// = to the right; elevation in degrees above the listener plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Speaker { + L, + R, + C, + Lfe, + /// 5.x surrounds (±110°). + Ls, + Rs, + /// SDDS inner fronts (±15°). + Lc, + Rc, + /// Centre surround (180°). + Cs, + /// 7.x side surrounds (±90°). + Lss, + Rss, + /// 7.x rear surrounds (±150°). + Lrs, + Rrs, + /// 9.x front wides (±60°). + Lw, + Rw, + /// Top front (±45°, elevation 45°). + Ltf, + Rtf, + /// Top middle (±90°, elevation 45°). + Ltm, + Rtm, + /// Top rear (±135°, elevation 45°). + Ltr, + Rtr, +} + +impl Speaker { + pub fn azimuth(self) -> f32 { + match self { + Speaker::L => -30.0, + Speaker::R => 30.0, + Speaker::C | Speaker::Lfe => 0.0, + Speaker::Ls => -110.0, + Speaker::Rs => 110.0, + Speaker::Lc => -15.0, + Speaker::Rc => 15.0, + Speaker::Cs => 180.0, + Speaker::Lss => -90.0, + Speaker::Rss => 90.0, + Speaker::Lrs => -150.0, + Speaker::Rrs => 150.0, + Speaker::Lw => -60.0, + Speaker::Rw => 60.0, + Speaker::Ltf => -45.0, + Speaker::Rtf => 45.0, + Speaker::Ltm => -90.0, + Speaker::Rtm => 90.0, + Speaker::Ltr => -135.0, + Speaker::Rtr => 135.0, + } + } + pub fn elevation(self) -> f32 { + if self.is_height() { 45.0 } else { 0.0 } + } + pub fn is_height(self) -> bool { + matches!(self, Speaker::Ltf | Speaker::Rtf | Speaker::Ltm | Speaker::Rtm | Speaker::Ltr | Speaker::Rtr) + } + pub fn is_lfe(self) -> bool { + self == Speaker::Lfe + } + pub fn label(self) -> &'static str { + match self { + Speaker::L => "L", + Speaker::R => "R", + Speaker::C => "C", + Speaker::Lfe => "LFE", + Speaker::Ls => "Ls", + Speaker::Rs => "Rs", + Speaker::Lc => "Lc", + Speaker::Rc => "Rc", + Speaker::Cs => "Cs", + Speaker::Lss => "Lss", + Speaker::Rss => "Rss", + Speaker::Lrs => "Lrs", + Speaker::Rrs => "Rrs", + Speaker::Lw => "Lw", + Speaker::Rw => "Rw", + Speaker::Ltf => "Ltf", + Speaker::Rtf => "Rtf", + Speaker::Ltm => "Ltm", + Speaker::Rtm => "Rtm", + Speaker::Ltr => "Ltr", + Speaker::Rtr => "Rtr", + } + } + /// WAVE_FORMAT_EXTENSIBLE `dwChannelMask` bit (0 for speakers WAV cannot name). + pub fn wave_bit(self) -> u32 { + match self { + Speaker::L => 0x1, + Speaker::R => 0x2, + Speaker::C => 0x4, + Speaker::Lfe => 0x8, + Speaker::Ls | Speaker::Lrs => 0x10, + Speaker::Rs | Speaker::Rrs => 0x20, + Speaker::Lc | Speaker::Lw => 0x40, + Speaker::Rc | Speaker::Rw => 0x80, + Speaker::Cs => 0x100, + Speaker::Lss => 0x200, + Speaker::Rss => 0x400, + Speaker::Ltf => 0x1000, + Speaker::Rtf => 0x4000, + Speaker::Ltr => 0x8000, + Speaker::Rtr => 0x2_0000, + Speaker::Ltm | Speaker::Rtm => 0, + } + } +} + +/// Surround panner position (Pro Tools-style X/Y puck). `None` on a mixer means "use the stereo +/// pan", which keeps sessions made before surround mixing sounding exactly as they did. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SurroundPan { + /// -1 (left) ..= 1 (right). + pub x: f32, + /// -1 (back) ..= 1 (front). + pub y: f32, + /// Elevation 0 (ear level) ..= 1 (overhead), used by height formats. + pub z: f32, + /// 0 = point source ..= 1 = spread over every speaker. + pub divergence: f32, + /// Centre speaker share of front-centre images, 0..=100 % (0 = phantom centre on L/R). + pub center: f32, + /// LFE send level, dB (-144 = off). + pub lfe_db: f32, +} + +impl Default for SurroundPan { + fn default() -> Self { + SurroundPan { x: 0.0, y: 1.0, z: 0.0, divergence: 0.0, center: 100.0, lfe_db: -144.0 } + } +} + +impl SurroundPan { + /// Puck at the stereo pan position (front edge). + pub fn from_stereo(pan: f32) -> Self { + SurroundPan { x: finite_or(pan, 0.0).clamp(-1.0, 1.0), ..SurroundPan::default() } + } + /// Clamp into range (NaN → defaults). + pub fn sanitize(&mut self) { + let d = SurroundPan::default(); + self.x = finite_or(self.x, d.x).clamp(-1.0, 1.0); + self.y = finite_or(self.y, d.y).clamp(-1.0, 1.0); + self.z = finite_or(self.z, d.z).clamp(0.0, 1.0); + self.divergence = finite_or(self.divergence, d.divergence).clamp(0.0, 1.0); + self.center = finite_or(self.center, d.center).clamp(0.0, 100.0); + self.lfe_db = finite_or(self.lfe_db, d.lfe_db).clamp(-144.0, 12.0); + } +} + +/// An internal mix bus (Pro Tools "bus" paths). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Bus { + pub id: BusId, + pub name: String, + pub format: ChannelFormat, +} + +/// Physical output paths (from the I/O setup). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct OutputPath { + pub name: String, + /// First hardware channel (0-based). + pub first_channel: u16, + pub format: ChannelFormat, +} + +/// Where a signal goes or comes from. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum Route { + #[default] + None, + /// The main output (hardware outputs 1-2, or the bounce source). + Main, + Bus(BusId), + /// Hardware input/output by path name. + Hardware(String), +} + +/// A plugin instance on an insert slot. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Insert { + /// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`). + pub plugin: String, + #[serde(default, deserialize_with = "finite_params")] + pub params: BTreeMap, + #[serde(default)] + pub bypass: bool, + #[serde(default = "yes")] + pub active: bool, + /// Preset name shown in the plugin window header. + #[serde(default)] + pub preset: String, + /// Opaque plugin state (third-party plugins: CLAP state / VST3 component + controller + /// state), standard base64. Restored into a new instance before it first processes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option, + /// The session audio source a sample-playing instrument (the built-in Sampler) plays. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample: Option, +} + +fn yes() -> bool { + true +} + +impl Insert { + pub fn new(plugin: impl Into) -> Self { + Insert { + plugin: plugin.into(), + params: BTreeMap::new(), + bypass: false, + active: true, + preset: String::from(""), + state: None, + sample: None, + } + } + + /// The decoded plugin state (`None` when absent or malformed). + pub fn state_bytes(&self) -> Option> { + self.state.as_deref().and_then(crate::b64::decode) + } + + /// Stores a plugin state blob (base64-encoded); an empty blob clears it. + pub fn set_state_bytes(&mut self, data: &[u8]) { + self.state = (!data.is_empty()).then(|| crate::b64::encode(data)); + } +} + +/// A send to a bus. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SendSlot { + pub target: Route, + pub level_db: f32, + pub pan: f32, + pub mute: bool, + pub pre_fader: bool, + #[serde(default = "yes")] + pub follow_main_pan: bool, + /// Surround position into a multichannel bus (None = follow the track / stereo pan). + #[serde(default)] + pub surround: Option, +} + +impl SendSlot { + pub fn new(target: Route) -> Self { + SendSlot { target, level_db: -144.0, pan: 0.0, mute: false, pre_fader: false, follow_main_pan: true, surround: None } + } +} + +/// The channel strip. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Mixer { + pub volume_db: f32, + /// One panner per source channel for mono/stereo (stereo tracks: two panners, default hard L/R). + pub pan: Vec, + pub mute: bool, + pub solo: bool, + #[serde(default)] + pub solo_safe: bool, + /// Live record-enable state. Runtime-only: opening a session must never arm an input. + #[serde(skip)] + pub record_arm: bool, + /// Live input-monitor state. Runtime-only: opening a session must never enable monitoring. + #[serde(skip)] + pub input_monitor: bool, + #[serde(default)] + pub phase_invert: bool, + pub input: Route, + pub output: Route, + pub inserts: Vec>, + pub sends: Vec>, + pub automation_mode: AutomationMode, + /// Input gain / trim, dB. + #[serde(default)] + pub trim_db: f32, + /// VCA master that controls this track, if any (track id of the VCA). + #[serde(default)] + pub vca: Option, + /// Surround panner, used when the output is multichannel (None = the stereo pan to L/R). + #[serde(default)] + pub surround: Option, +} + +impl Mixer { + pub fn new(format: ChannelFormat) -> Self { + let pan = match format { + ChannelFormat::Mono => vec![0.0], + ChannelFormat::Stereo => vec![-1.0, 1.0], + _ => Vec::new(), + }; + Mixer { + volume_db: 0.0, + pan, + mute: false, + solo: false, + solo_safe: false, + record_arm: false, + input_monitor: false, + phase_invert: false, + input: Route::None, + output: Route::Main, + inserts: vec![None; INSERT_SLOTS], + sends: vec![None; SEND_SLOTS], + automation_mode: AutomationMode::Read, + trim_db: 0.0, + vca: None, + surround: None, + } + } + + /// Clamp everything into range (after deserialising untrusted files). + pub fn sanitize(&mut self) { + self.volume_db = finite_or(self.volume_db, 0.0).clamp(-144.0, 12.0); + for p in &mut self.pan { + *p = finite_or(*p, 0.0).clamp(-1.0, 1.0); + } + self.inserts.resize(INSERT_SLOTS, None); + self.sends.resize(SEND_SLOTS, None); + for s in self.sends.iter_mut().flatten() { + s.level_db = finite_or(s.level_db, -144.0).clamp(-144.0, 12.0); + s.pan = finite_or(s.pan, 0.0).clamp(-1.0, 1.0); + if let Some(sp) = &mut s.surround { + sp.sanitize(); + } + } + if let Some(sp) = &mut self.surround { + sp.sanitize(); + } + self.trim_db = finite_or(self.trim_db, 0.0).clamp(-144.0, 24.0); + } +} + +impl crate::Session { + /// The main mix format: the format of the first (main) output path, at least stereo and at + /// most 16 channels (wider formats fall back to stereo). + pub fn main_format(&self) -> ChannelFormat { + match self.outputs.first().map(|o| o.format) { + Some(f) if (2..=16).contains(&f.channels()) => f, + _ => ChannelFormat::Stereo, + } + } +} + +pub(crate) fn finite_or(v: f32, d: f32) -> f32 { + if v.is_finite() { v } else { d } +} + +/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so +/// they keep their defaults instead of failing the whole session. +fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let params: BTreeMap> = serde::Deserialize::deserialize(d)?; + Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect()) +} + +/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12). +pub fn fader_pos_to_db(pos: f32) -> f32 { + let p = finite_or(pos, 0.0).clamp(0.0, 1.0); + if p <= 0.0 { + return -144.0; + } + // Piecewise: top 75 % covers -24..+12 linearly-ish, the bottom compresses to -inf. + if p >= 0.25 { -24.0 + (p - 0.25) / 0.75 * 36.0 } else { -24.0 - (1.0 - p / 0.25).powf(1.5) * 120.0 } +} + +pub fn fader_db_to_pos(db: f32) -> f32 { + let db = finite_or(db, -144.0); + if db <= -144.0 { + return 0.0; + } + if db >= -24.0 { + (0.25 + (db.min(12.0) + 24.0) / 36.0 * 0.75).min(1.0) + } else { + let x = ((-24.0 - db) / 120.0).clamp(0.0, 1.0).powf(1.0 / 1.5); + ((1.0 - x) * 0.25).max(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_state_round_trips_through_json() { + let mut i = Insert::new("vst3:00"); + assert_eq!(i.state_bytes(), None); + let blob: Vec = (0..=255u8).cycle().take(1000).collect(); + i.set_state_bytes(&blob); + let json = serde_json::to_string(&i).unwrap(); + let back: Insert = serde_json::from_str(&json).unwrap(); + assert_eq!(back.state_bytes().unwrap(), blob); + i.set_state_bytes(&[]); + assert_eq!(i.state, None); + // Old sessions have no `state`; stateless inserts don't write one. + let old: Insert = serde_json::from_str(r#"{"plugin":"eq_7band"}"#).unwrap(); + assert_eq!(old.state, None); + assert!(!serde_json::to_string(&old).unwrap().contains("state")); + // A hostile state string decodes to nothing rather than failing the load. + let bad: Insert = serde_json::from_str(r#"{"plugin":"x","state":"!!!"}"#).unwrap(); + assert_eq!(bad.state_bytes(), None); + } + + #[test] + fn params_saved_as_null_or_out_of_range_are_dropped() { + let mut i = Insert::new("eq_7band"); + i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]); + let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap(); + assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)])); + let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap(); + assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)])); + } + + #[test] + fn fader_taper_round_trips() { + for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] { + let p = fader_db_to_pos(db); + assert!((fader_pos_to_db(p) - db).abs() < 0.05, "{db} → {p}"); + } + assert_eq!(fader_pos_to_db(0.0), -144.0); + assert_eq!(fader_db_to_pos(f32::NAN), 0.0); + } + + #[test] + fn mixer_defaults_and_sanitize() { + let mut m = Mixer::new(ChannelFormat::Stereo); + assert_eq!(m.pan, vec![-1.0, 1.0]); + m.volume_db = f32::INFINITY; + m.inserts.truncate(2); + m.sanitize(); + assert_eq!(m.volume_db, 0.0); + assert_eq!(m.inserts.len(), INSERT_SLOTS); + } + + #[test] + fn speaker_layouts_match_channel_counts_and_masks() { + for f in ChannelFormat::ALL { + if !f.is_ambisonic() { + assert_eq!(f.speakers().len(), f.channels(), "{f:?}"); + // Mask bits are unique and in ascending channel order. + let bits: Vec = f.speakers().iter().map(|s| s.wave_bit()).filter(|b| *b != 0).collect(); + assert!(bits.windows(2).all(|w| w[0] < w[1]), "{f:?} not in WAV order"); + } + } + assert_eq!(ChannelFormat::Surround51.channel_mask(), 0x3F); + assert_eq!(ChannelFormat::Surround71.channel_mask(), 0x63F); + assert_eq!(ChannelFormat::Atmos714.channel_mask(), 0x2_D63F); + assert_eq!(ChannelFormat::Ambisonics(1).channel_mask(), 0); + } + + #[test] + fn surround_pan_defaults_and_old_sessions() { + let m = Mixer::new(ChannelFormat::Mono); + assert_eq!(m.surround, None); + // A mixer saved before surround existed still loads. + let mut v = serde_json::to_value(&m).unwrap(); + v.as_object_mut().unwrap().remove("surround"); + let back: Mixer = serde_json::from_value(v).unwrap(); + assert_eq!(back.surround, None); + let mut sp: SurroundPan = serde_json::from_str(r#"{"x": 5.0}"#).unwrap(); + sp.sanitize(); + assert_eq!(sp.x, 1.0); + assert_eq!(sp.center, 100.0); + } + + #[test] + fn formats() { + assert_eq!(ChannelFormat::for_channels(6), ChannelFormat::Surround51); + assert_eq!(ChannelFormat::from_id("stereo"), Some(ChannelFormat::Stereo)); + } +} diff --git a/crates/scraft-format/src/session.rs b/crates/scraft-format/src/session.rs new file mode 100644 index 000000000..5f73f940e --- /dev/null +++ b/crates/scraft-format/src/session.rs @@ -0,0 +1,1319 @@ +//! The session: the whole document. + +use crate::clip::{bound, bound_sequence}; +use crate::soundcraft_time::{FrameRate, GridScale, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat}; +use crate::{ + Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool, + TRACK_COLORS, Track, TrackId, TrackKind, VideoSource, +}; + +/// Native session file extension. +pub const SESSION_EXTENSION: &str = "scraft"; + +/// `EditState::values` key holding the feet+frames reading of sample 0, in frames. +pub const FEET_FRAMES_START: &str = "feet_frames.start"; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SessionError { + #[error("not a SoundCraft session: {0}")] + Format(String), + #[error("session file version {0} is newer than this SoundCraft supports")] + Version(u32), + #[error("{0}")] + Invalid(String), +} + +/// Session bit depth for recording/rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum BitDepthSetting { + Int16, + #[default] + Int24, + Float32, +} + +/// Edit modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum EditMode { + Shuffle, + #[default] + Slip, + Spot, + Grid, + /// Relative grid: moves keep their offset from the grid. + GridRelative, +} + +impl EditMode { + pub fn label(self) -> &'static str { + match self { + EditMode::Shuffle => "SHUFFLE", + EditMode::Slip => "SLIP", + EditMode::Spot => "SPOT", + EditMode::Grid => "GRID", + EditMode::GridRelative => "REL GRID", + } + } + pub fn from_id(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "shuffle" => Some(EditMode::Shuffle), + "slip" => Some(EditMode::Slip), + "spot" => Some(EditMode::Spot), + "grid" | "absolute_grid" => Some(EditMode::Grid), + "relative_grid" | "rel_grid" | "grid_relative" => Some(EditMode::GridRelative), + _ => None, + } + } + pub fn is_grid(self) -> bool { + matches!(self, EditMode::Grid | EditMode::GridRelative) + } +} + +/// Edit tools. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum Tool { + Zoom, + Trim, + #[default] + Selector, + Grabber, + Scrubber, + Pencil, + Smart, +} + +impl Tool { + pub const ALL: [Tool; 7] = [Tool::Zoom, Tool::Trim, Tool::Selector, Tool::Grabber, Tool::Scrubber, Tool::Pencil, Tool::Smart]; + pub fn id(self) -> &'static str { + match self { + Tool::Zoom => "zoom", + Tool::Trim => "trim", + Tool::Selector => "selector", + Tool::Grabber => "grabber", + Tool::Scrubber => "scrubber", + Tool::Pencil => "pencil", + Tool::Smart => "smart", + } + } + pub fn label(self) -> &'static str { + match self { + Tool::Zoom => "Zoomer", + Tool::Trim => "Trim", + Tool::Selector => "Selector", + Tool::Grabber => "Grabber", + Tool::Scrubber => "Scrubber", + Tool::Pencil => "Pencil", + Tool::Smart => "Smart Tool", + } + } + pub fn from_id(s: &str) -> Option { + Tool::ALL.into_iter().find(|t| t.id() == s.to_ascii_lowercase()) + } + /// F-key shortcut (F5..F10) like the incumbent. + pub fn fkey(self) -> Option<&'static str> { + match self { + Tool::Zoom => Some("F5"), + Tool::Trim => Some("F6"), + Tool::Selector => Some("F7"), + Tool::Grabber => Some("F8"), + Tool::Scrubber => Some("F9"), + Tool::Pencil => Some("F10"), + Tool::Smart => None, + } + } +} + +/// Track view lanes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum TrackView { + Waveform, + Blocks, + Volume, + Pan, + Mute, +} + +/// Horizontal zoom + scroll. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomState { + /// Samples per pixel (point). + pub samples_per_px: f64, + /// Left edge of the visible timeline. + pub scroll: Samples, + /// Waveform vertical zoom factor. + pub waveform_zoom: f32, + /// Zoom preset memories 1..5. + pub presets: [f64; 5], +} + +impl Default for ZoomState { + /// Starts with a practical overview zoom and five reusable preset levels. + fn default() -> Self { + ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] } + } +} + +/// The view Zoom Toggle goes back to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomToggle { + pub samples_per_px: f64, + pub scroll: Samples, + pub heights: Vec<(TrackId, crate::TrackHeight)>, +} + +/// Edit-window state saved with the session (selection, tools, modes, view settings). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EditState { + pub tool: Tool, + pub edit_mode: EditMode, + /// The edit selection (a point = insertion position). + pub selection: Range, + /// Tracks in the edit selection. + pub selected_tracks: Vec, + /// Clips explicitly selected (Grabber selection). + #[serde(default)] + pub selected_clips: Vec, + /// Audio files selected in the Clip List. + #[serde(default)] + pub selected_sources: Vec, + /// Playhead position when stopped. + pub playhead: Samples, + pub grid: GridValue, + pub nudge: GridValue, + /// Grid/Nudge time scales follow the main counter when enabled. + #[serde(default)] + pub grid_follow_main: bool, + #[serde(default)] + pub nudge_follow_main: bool, + /// Grid lines and snapping use visible clip boundaries and memory locations while the numeric + /// Grid value is retained for when this mode is left. + #[serde(default)] + pub grid_clips_markers: bool, + #[serde(default = "yes")] + pub grid_lines: bool, + pub main_counter: TimeFormat, + pub sub_counter: Option, + pub zoom: ZoomState, + /// Set while Zoom Toggle is on. + pub zoom_toggle: Option, + /// Visible rulers. + pub rulers: Vec, + pub link_timeline_edit: bool, + pub link_track_edit: bool, + pub insertion_follows_playback: bool, + pub loop_playback: bool, + pub pre_post_roll: bool, + pub pre_roll: Samples, + pub post_roll: Samples, + pub tab_to_transient: bool, + pub automation_follows_edit: bool, + pub markers_follow_edit: bool, + pub layered_editing: bool, + pub mirrored_midi: bool, + pub click: bool, + pub countoff: bool, + pub countoff_bars: u32, + pub midi_merge: bool, + pub record_mode: String, + pub loop_record: bool, + pub quickpunch: bool, + pub scrolling: String, + pub solo_mode: String, + pub pre_fader_metering: bool, + pub delay_compensation: bool, + pub keyboard_focus: String, + /// Timeline selection when it is unlinked from the edit selection. + pub timeline_selection: Range, + /// Generic on/off view and option flags keyed by id (e.g. `view.clip.name`, `waveform.rectified`, + /// `options.midi_thru`). Saved with the session so every view setting persists. + #[serde(default)] + pub flags: std::collections::BTreeSet, + /// Numeric settings keyed by id (e.g. `click.volume_db`, `engine.buffer_size`). + #[serde(default)] + pub values: std::collections::BTreeMap, +} + +impl EditState { + pub fn flag(&self, id: &str) -> bool { + self.flags.contains(id) + } + pub fn set_flag(&mut self, id: &str, on: bool) { + if on { + self.flags.insert(id.to_string()); + } else { + self.flags.remove(id); + } + } + pub fn value(&self, id: &str, default: f64) -> f64 { + self.values.get(id).copied().filter(|v| v.is_finite()).unwrap_or(default) + } +} + +fn yes() -> bool { + true +} + +impl Default for EditState { + /// Starts the Edit window in Slip mode with independent Grid and Nudge scales. + fn default() -> Self { + EditState { + tool: Tool::Smart, + edit_mode: EditMode::Slip, + selection: Range::default(), + selected_tracks: Vec::new(), + selected_clips: Vec::new(), + selected_sources: Vec::new(), + playhead: 0, + grid: GridValue::default(), + nudge: GridValue::Seconds(1.0), + grid_follow_main: false, + nudge_follow_main: false, + grid_clips_markers: false, + grid_lines: true, + main_counter: TimeFormat::MinSecs, + sub_counter: Some(TimeFormat::Samples), + zoom: ZoomState::default(), + zoom_toggle: None, + rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()], + link_timeline_edit: true, + link_track_edit: false, + insertion_follows_playback: false, + loop_playback: false, + pre_post_roll: false, + pre_roll: 96_000, + post_roll: 96_000, + tab_to_transient: false, + automation_follows_edit: true, + markers_follow_edit: false, + layered_editing: false, + mirrored_midi: false, + click: false, + countoff: false, + countoff_bars: 2, + midi_merge: false, + record_mode: "normal".into(), + loop_record: false, + quickpunch: false, + scrolling: "page".into(), + solo_mode: "sip".into(), + pre_fader_metering: false, + delay_compensation: true, + keyboard_focus: "commands".into(), + timeline_selection: Range::default(), + flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"] + .iter() + .map(ToString::to_string) + .collect(), + values: std::collections::BTreeMap::new(), + } + } +} + +/// Clip List › Auto Rename: the names that editing gives the clips it separates from a clip. +/// Each new clip is named `name`, then `zeros` zeros, then `number`, then `suffix`, and `number` +/// counts up by one for the next clip. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct AutoName { + pub name: String, + /// The number the next new clip gets. + pub number: u64, + pub zeros: usize, + pub suffix: String, + /// Tells this scheme from another one under the same key after an undo or in another session, + /// for pastes from the clipboard. 0 matches no scheme. + #[serde(default)] + pub id: u64, +} + +impl AutoName { + /// The largest starting number. + pub const MAX_NUMBER: u64 = 999_999_999; + /// The most zeros before the number. + pub const MAX_ZEROS: usize = 9; + + /// Room for `name` and `suffix` together: 29 characters stay free for 9 zeros and the 20 + /// digits of a u64, so a clip name never loses its number to [`MAX_NAME_CHARS`]. + pub const MAX_TEXT_CHARS: usize = MAX_NAME_CHARS - 29; + + /// Repair a scheme read from a file: names that can be drawn and at most [`Self::MAX_ZEROS`] zeros. + pub fn sanitize(&mut self) { + truncate_chars(&mut self.name, Self::MAX_TEXT_CHARS); + truncate_chars(&mut self.suffix, Self::MAX_TEXT_CHARS - self.name.chars().count()); + self.zeros = self.zeros.min(Self::MAX_ZEROS); + } +} + +/// The document. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Session { + pub format_version: u32, + pub name: String, + pub sample_rate: SampleRate, + pub bit_depth: BitDepthSetting, + pub frame_rate: FrameRate, + /// Timecode of sample 0 (session start), as a sample count. + pub timecode_start: Samples, + pub tempo: TempoMap, + #[serde(default)] + pub key_signatures: Vec<(Samples, String)>, + pub tracks: Vec, + pub sources: Vec, + /// Movies referenced by Video track clips. + #[serde(default)] + pub videos: Vec, + pub busses: Vec, + pub outputs: Vec, + pub markers: Vec, + pub groups: Vec, + /// Clip List › Auto Rename schemes, keyed by the ids in [`Clip::auto_name`]. The clips separated + /// from one clip share its scheme, so they count up one number sequence. + #[serde(default)] + pub auto_names: std::collections::BTreeMap, + pub edit: EditState, + pub next_id: u64, + #[serde(default)] + pub comments: String, + /// Original MIDI performances keyed by clip id (Event › MIDI Operations › Restore/Flatten + /// Performance). Independent of undo so a restore survives later edits. + #[serde(default)] + pub midi_originals: std::collections::BTreeMap, + #[serde(skip)] + pub pool: SourcePool, + /// The folder relative source paths point into: where the session was opened or last saved. + #[serde(skip)] + pub folder: Option, +} + +impl Default for Session { + fn default() -> Self { + Session::new("Untitled", SampleRate::HZ_48000) + } +} + +impl Session { + pub const FORMAT_VERSION: u32 = 1; + + pub fn new(name: impl Into, sample_rate: SampleRate) -> Self { + let mut s = Session { + format_version: Self::FORMAT_VERSION, + name: name.into(), + sample_rate, + bit_depth: BitDepthSetting::Int24, + frame_rate: FrameRate::Fps30, + timecode_start: 0, + tempo: TempoMap::default(), + key_signatures: Vec::new(), + tracks: Vec::new(), + sources: Vec::new(), + videos: Vec::new(), + busses: Vec::new(), + outputs: vec![OutputPath { name: "Out 1-2".into(), first_channel: 0, format: ChannelFormat::Stereo }], + markers: Vec::new(), + groups: Vec::new(), + auto_names: std::collections::BTreeMap::new(), + edit: EditState::default(), + next_id: 1, + comments: String::new(), + midi_originals: std::collections::BTreeMap::new(), + pool: SourcePool::default(), + folder: None, + }; + s.edit.pre_roll = sample_rate.samples(2.0); + s.edit.post_roll = sample_rate.samples(2.0); + s + } + + /// Allocate a fresh id. + pub fn alloc(&mut self) -> u64 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + // ---- lookups ------------------------------------------------------------------------- + + pub fn track(&self, id: TrackId) -> Option<&Track> { + self.tracks.iter().find(|t| t.id == id) + } + pub fn track_mut(&mut self, id: TrackId) -> Option<&mut Track> { + self.tracks.iter_mut().find(|t| t.id == id) + } + pub fn track_index(&self, id: TrackId) -> Option { + self.tracks.iter().position(|t| t.id == id) + } + pub fn track_by_name(&self, name: &str) -> Option<&Track> { + self.tracks.iter().find(|t| t.name == name).or_else(|| self.tracks.iter().find(|t| t.name.eq_ignore_ascii_case(name))) + } + pub fn master(&self) -> Option<&Track> { + self.tracks.iter().find(|t| t.kind == TrackKind::Master) + } + pub fn source(&self, id: SourceId) -> Option<&Source> { + self.sources.iter().find(|s| s.id == id) + } + pub fn video(&self, id: SourceId) -> Option<&VideoSource> { + self.videos.iter().find(|v| v.id == id) + } + pub fn bus(&self, id: BusId) -> Option<&Bus> { + self.busses.iter().find(|b| b.id == id) + } + pub fn bus_by_name(&self, name: &str) -> Option<&Bus> { + self.busses.iter().find(|b| b.name.eq_ignore_ascii_case(name)) + } + pub fn group(&self, id: GroupId) -> Option<&Group> { + self.groups.iter().find(|g| g.id == id) + } + pub fn marker(&self, id: MarkerId) -> Option<&MemoryLocation> { + self.markers.iter().find(|m| m.id == id) + } + + /// Find a clip anywhere (active playlists). + pub fn find_clip(&self, id: ClipId) -> Option<(TrackId, &Clip)> { + self.tracks.iter().find_map(|t| t.clips().iter().find(|c| c.id == id).map(|c| (t.id, c))) + } + + pub fn find_clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> { + self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id))) + } + + /// Clips on every playlist, alternates included. + pub fn all_clips(&self) -> impl Iterator { + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips)) + } + + pub fn clips_using(&self, src: SourceId) -> impl Iterator { + self.all_clips().filter(move |c| c.source() == Some(src)) + } + + /// End of the last clip on any track (session length). + pub fn content_end(&self) -> Samples { + self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) + } + + /// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position). + pub fn feet_frames_start(&self) -> i64 { + self.edit.value(FEET_FRAMES_START, 0.0) as i64 + } + + /// Format a position against this session's timecode and feet+frames references. + pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String { + crate::soundcraft_time::format_position( + at, + fmt, + self.sample_rate, + &self.tempo, + self.frame_rate, + self.timecode_start, + self.feet_frames_start(), + ) + } + + /// Parse a position typed in `fmt` against this session's references. + pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result { + crate::soundcraft_time::parse_position( + text, + fmt, + self.sample_rate, + &self.tempo, + self.frame_rate, + self.timecode_start, + self.feet_frames_start(), + ) + } + + /// Whether the Edit window shows a track: unhidden, with every parent folder open. + pub fn track_shown(&self, track: &Track) -> bool { + if track.hidden { + return false; + } + let mut folder = track.folder; + for _ in 0..64 { + let Some(parent) = folder.and_then(|id| self.track(id)) else { return true }; + if !parent.folder_open { + return false; + } + folder = parent.folder; + } + false + } + + /// Collects unique visible active-playlist clip boundaries and memory-location boundaries. + fn clip_marker_grid_points(&self, keep: impl Fn(Samples) -> bool, excluded: &[ClipId], include_zero: bool) -> Vec { + let mut points = Vec::new(); + let excluded: std::collections::HashSet = excluded.iter().copied().collect(); + if include_zero && keep(0) { + points.push(0); + } + for clip in + self.tracks.iter().filter(|track| self.track_shown(track)).flat_map(|track| track.clips()).filter(|clip| !excluded.contains(&clip.id)) + { + points.extend([clip.start, clip.end()].into_iter().filter(|point| keep(*point))); + } + for marker in &self.markers { + if keep(marker.start) { + points.push(marker.start); + } + if marker.end != marker.start && keep(marker.end) { + points.push(marker.end); + } + } + points.sort_unstable(); + points.dedup(); + points + } + + /// Sample-domain Timecode reference; Feet+Frames applies its origin in frames separately. + fn grid_origin(&self) -> Samples { + match self.edit.grid.scale() { + GridScale::Timecode => self.timecode_start, + _ => 0, + } + } + + /// Grid lines for the current Grid value, including clip and marker boundaries. + pub fn grid_lines(&self, start: Samples, end: Samples, max: usize) -> Vec { + if !self.edit.grid_clips_markers { + if let GridValue::FeetFrames(value) = self.edit.grid { + return value.lines(start, end, self.sample_rate, self.frame_rate, self.feet_frames_start(), max); + } + let origin = self.grid_origin(); + return self + .edit + .grid + .lines(start.saturating_add(origin), end.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate, max) + .into_iter() + .map(|point| point.saturating_sub(origin)) + .collect(); + } + let points = self.clip_marker_grid_points(|point| point >= start && point < end, &[], true); + if points.len() <= max { + return points; + } + match max { + 0 => Vec::new(), + 1 => points.first().copied().into_iter().collect(), + _ => { + let span = i128::from(end).saturating_sub(i128::from(start)).max(1); + let mut bucket = None; + points + .into_iter() + .filter(|point| { + let next = i128::from(*point).saturating_sub(i128::from(start)).saturating_mul(max as i128) / span; + if bucket == Some(next) { + false + } else { + bucket = Some(next); + true + } + }) + .collect() + } + } + } + + /// The current Grid line at or before `position`. + pub fn grid_line_at_or_before(&self, position: Samples) -> Samples { + self.grid_line_at_or_before_excluding(position, &[]) + } + + /// Find the current Grid line at or before `position` without boundaries of `excluded` clips. + pub fn grid_line_at_or_before_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples { + if !self.edit.grid_clips_markers { + if let GridValue::FeetFrames(value) = self.edit.grid { + return value.line_at_or_before(position, self.sample_rate, self.frame_rate, self.feet_frames_start()); + } + let origin = self.grid_origin(); + return self + .edit + .grid + .line_at_or_before(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate) + .saturating_sub(origin); + } + self.clip_marker_grid_points(|_| true, excluded, true).into_iter().take_while(|point| *point <= position).last().unwrap_or(position) + } + + /// Snap to the nearest line in the current Grid. + pub fn snap_to_grid(&self, position: Samples) -> Samples { + self.snap_to_grid_excluding(position, &[]) + } + + /// Snap to the nearest current Grid line without using boundaries of `excluded` clips. + pub fn snap_to_grid_excluding(&self, position: Samples, excluded: &[ClipId]) -> Samples { + if !self.edit.grid_clips_markers { + if let GridValue::FeetFrames(value) = self.edit.grid { + return value.snap(position, self.sample_rate, self.frame_rate, self.feet_frames_start()); + } + let origin = self.grid_origin(); + return self.edit.grid.snap(position.saturating_add(origin), self.sample_rate, &self.tempo, self.frame_rate).saturating_sub(origin); + } + let mut points = self.clip_marker_grid_points(|_| true, excluded, false); + if !points.is_empty() { + points.push(0); + } + points.into_iter().min_by_key(|point| i128::from(*point).saturating_sub(i128::from(position)).abs()).unwrap_or(position) + } + + pub fn next_track_color(&self) -> [u8; 3] { + let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count(); + TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128]) + } + + /// A unique track name: `base`, or `base 1`, `base 2` … like the incumbent ("Audio 1"). + pub fn unique_track_name(&self, base: &str) -> String { + let taken = |n: &str| self.tracks.iter().any(|t| t.name == n); + if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") { + return base.to_string(); + } + let prefix = format!("{base} "); + let used: std::collections::HashSet = self + .tracks + .iter() + .filter_map(|t| t.name.strip_prefix(&prefix)) + .filter_map(|n| n.parse().ok().filter(|i: &u64| i.to_string() == n)) + .collect(); + let i = (1..).find(|i| !used.contains(i)).unwrap_or(1); + format!("{base} {i}") + } + + /// Add a track with defaults; returns its id. + pub fn add_track(&mut self, kind: TrackKind, format: ChannelFormat, name: Option<&str>) -> TrackId { + let id = TrackId(self.alloc()); + let name = self.unique_track_name(name.unwrap_or(kind.default_name())); + let color = if kind == TrackKind::Master { [140, 140, 140] } else { self.next_track_color() }; + let mut t = Track::new(id, name, kind, format, color); + if kind == TrackKind::Master { + t.mixer.output = crate::Route::Main; + t.mixer.input = crate::Route::Main; + } + // Master faders sit at the bottom like the incumbent; others go before any master. + let pos = if kind == TrackKind::Master { + self.tracks.len() + } else { + self.tracks.iter().position(|t| t.kind == TrackKind::Master).unwrap_or(self.tracks.len()) + }; + self.tracks.insert(pos, t); + id + } + + pub fn add_bus(&mut self, name: &str, format: ChannelFormat) -> BusId { + if let Some(b) = self.bus_by_name(name) { + return b.id; + } + let id = BusId(self.alloc()); + self.busses.push(Bus { id, name: name.to_string(), format }); + id + } + + /// Add a memory location; returns its id. + pub fn add_marker(&mut self, name: &str, kind: MarkerKind, start: Samples, end: Samples) -> MarkerId { + let id = MarkerId(self.alloc()); + let number = self.markers.iter().map(|m| m.number).max().unwrap_or(0).saturating_add(1); + self.markers.push(MemoryLocation { + id, + number, + name: name.to_string(), + kind, + start, + end: end.max(start), + comments: String::new(), + color: None, + ruler: 1, + track: None, + }); + self.markers.sort_by_key(|m| m.start); + id + } + + pub fn new_clip_id(&mut self) -> ClipId { + ClipId(self.alloc()) + } + + /// Drop the Auto Rename schemes that no clip on any playlist uses. + pub fn prune_auto_names(&mut self) { + let used: std::collections::BTreeSet = self.all_clips().filter_map(|c| c.auto_name).collect(); + self.auto_names.retain(|k, _| used.contains(k)); + } + + // ---- persistence --------------------------------------------------------------------- + + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| SessionError::Format(e.to_string())) + } + + /// Parse a session; decoded audio must be attached separately (`pool`). + /// + /// The text is parsed straight into the document, never into a `serde_json::Value` tree + /// first (which takes many times the file's size). Only the version is read before that. + /// A field of the document given twice in one object is refused (`duplicate field`), as no + /// writer repeats one. Keys of maps (plugin params, edit values, Auto Rename schemes, MIDI + /// originals) and unknown keys keep the last value given, as before. + pub fn from_json(text: &str) -> Result { + /// The version alone: every other field is skipped, not kept. + #[derive(serde::Deserialize)] + struct Head { + format_version: Option, + } + // `Head` would also take a JSON array as its fields in order: only an object has a version. + let object = text.trim_start_matches([' ', '\t', '\n', '\r']).starts_with('{'); + let version = match object.then(|| serde_json::from_str::(text)) { + Some(Ok(head)) => head.format_version, + // A version given twice is that error, not a missing version. + Some(Err(e)) if e.to_string().starts_with("duplicate field") => return Err(SessionError::Format(e.to_string())), + // Valid JSON of another shape (not an object, or a version that is not a number) + // has no version; anything else is not JSON. + _ => { + serde_json::from_str::(text).map_err(|e| SessionError::Format(e.to_string()))?; + None + } + }; + let ver = version.and_then(|v| v.as_u64()).ok_or_else(|| SessionError::Format("missing format_version".into()))?; + if ver > u64::from(Self::FORMAT_VERSION) { + return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX))); + } + let mut s: Session = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?; + s.sanitize(); + s.validate()?; + Ok(s) + } + + /// Repair invariants after loading untrusted data. + pub fn sanitize(&mut self) { + let max_id = self + .tracks + .iter() + .map(|t| t.id.0) + .chain(self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0)))) + .chain(self.sources.iter().map(|s| s.id.0)) + .chain(self.auto_names.keys().copied()) + .chain(self.videos.iter().map(|v| v.id.0)) + .chain(self.busses.iter().map(|b| b.id.0)) + .chain(self.markers.iter().map(|m| m.id.0)) + .chain(self.groups.iter().map(|g| g.id.0)) + .max() + .unwrap_or(0); + self.next_id = self.next_id.max(max_id.saturating_add(1)); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + truncate_chars(&mut self.comments, MAX_COMMENT_CHARS); + for t in &mut self.tracks { + t.mixer.sanitize(); + truncate_chars(&mut t.name, MAX_NAME_CHARS); + truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); + if t.playlists.is_empty() { + t.playlists.push(crate::Playlist::new(t.name.clone())); + } + if t.active_playlist >= t.playlists.len() { + t.active_playlist = 0; + } + for p in &mut t.playlists { + truncate_chars(&mut p.name, MAX_NAME_CHARS); + p.clips.retain(|c| c.length > 0); + p.clips.iter_mut().for_each(Clip::sanitize); + p.sort(); + } + for l in &mut t.automation { + l.points.retain(|p| p.value.is_finite()); + let (lo, hi, _) = l.param.range(); + l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi)); + l.points.iter_mut().for_each(|p| bound(&mut p.at)); + l.points.sort_by_key(|p| p.at); + } + } + for m in &mut self.markers { + bound(&mut m.start); + bound(&mut m.end); + truncate_chars(&mut m.name, MAX_NAME_CHARS); + truncate_chars(&mut m.comments, MAX_COMMENT_CHARS); + } + for (at, key) in &mut self.key_signatures { + bound(at); + truncate_chars(key, MAX_NAME_CHARS); + } + self.midi_originals.values_mut().for_each(bound_sequence); + let e = &mut self.edit; + for at in [ + &mut e.selection.start, + &mut e.selection.end, + &mut e.timeline_selection.start, + &mut e.timeline_selection.end, + &mut e.playhead, + &mut e.pre_roll, + &mut e.post_roll, + &mut e.zoom.scroll, + &mut self.timecode_start, + ] { + bound(at); + } + for v in &mut self.videos { + if !v.frame_rate.is_finite() || v.frame_rate < 0.0 { + v.frame_rate = 0.0; + } + if !v.duration.is_finite() || v.duration < 0.0 { + v.duration = 0.0; + } + truncate_chars(&mut v.name, MAX_NAME_CHARS); + } + self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS)); + self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS)); + self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS)); + self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS)); + // Drop performances whose clip no longer exists anywhere (any playlist). + let clip_ids: std::collections::BTreeSet = + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect(); + self.midi_originals.retain(|id, _| clip_ids.contains(id)); + self.prune_auto_names(); + self.auto_names.values_mut().for_each(crate::AutoName::sanitize); + if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { + self.edit.zoom.samples_per_px = 1024.0; + } + if SampleRate::new(self.sample_rate.hz()).is_err() { + self.sample_rate = SampleRate::default(); + } + self.tempo.sanitize(); + } + + /// Reject a document whose ids are ambiguous or exhausted, run after [`Session::sanitize`] on + /// untrusted data. Duplicate ids in a namespace make commands, selections and source + /// associations act on an arbitrary one of the objects; a maximal `next_id` -- or a maximal + /// existing id, which `sanitize` folds into `next_id` -- makes every later allocation return + /// `u64::MAX` and repeat itself. + fn validate(&self) -> Result<(), SessionError> { + if self.next_id == u64::MAX { + return Err(SessionError::Invalid("the session id space is exhausted".into())); + } + distinct("track", self.tracks.iter().map(|t| t.id.0))?; + distinct("clip", self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))?; + distinct("source", self.sources.iter().map(|s| s.id.0))?; + distinct("video", self.videos.iter().map(|v| v.id.0))?; + distinct("bus", self.busses.iter().map(|b| b.id.0))?; + distinct("marker", self.markers.iter().map(|m| m.id.0))?; + distinct("group", self.groups.iter().map(|g| g.id.0))?; + Ok(()) + } +} + +/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing +/// a name of a million characters overflows the renderer's buffers. +pub const MAX_NAME_CHARS: usize = 256; +/// The most characters kept of a comment read from a file. +pub const MAX_COMMENT_CHARS: usize = 4096; + +/// Cut `s` to at most `max` characters. +pub fn truncate_chars(s: &mut String, max: usize) { + if let Some((end, _)) = s.char_indices().nth(max) { + s.truncate(end); + } +} + +/// Every id in `ids` distinct, or an error naming the namespace and the repeated id. +fn distinct(kind: &str, ids: impl Iterator) -> Result<(), SessionError> { + let mut seen = std::collections::HashSet::new(); + for id in ids { + if !seen.insert(id) { + return Err(SessionError::Invalid(format!("duplicate {kind} id {id}"))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_tracks_names_and_order() { + let mut s = Session::default(); + let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + let a = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let b = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let v = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal")); + assert_eq!(s.track(a).map(|t| t.name.as_str()), Some("Audio 1")); + assert_eq!(s.track(b).map(|t| t.name.as_str()), Some("Audio 2")); + assert_eq!(s.track(v).map(|t| t.name.as_str()), Some("Vocal")); + assert_eq!(s.tracks.last().map(|t| t.id), Some(m)); + let v2 = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal")); + assert_eq!(s.track(v2).map(|t| t.name.as_str()), Some("Vocal 1")); + } + + #[test] + fn json_round_trip() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + let cid = s.new_clip_id(); + if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) { + p.clips.push(Clip::audio(cid, "c", SourceId(99), 0, 10, 100)); + } + s.add_marker("Verse", MarkerKind::Marker, 48_000, 48_000); + let text = s.to_json().unwrap(); + let back = Session::from_json(&text).unwrap(); + assert_eq!(back, s); + } + + /// Clips/Markers mode snaps and draws at clip and memory-location boundaries. + #[test] + fn clips_markers_grid_uses_visible_clip_edges_and_memory_locations() { + let mut s = Session::default(); + let track = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let clip = Clip::midi(s.new_clip_id(), "Notes", 1_000, 2_000, crate::soundcraft_midi::Sequence::default()); + let clip_id = clip.id; + s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(clip); + let hidden = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let hidden_clip = Clip::midi(s.new_clip_id(), "Hidden", 3_200, 100, crate::soundcraft_midi::Sequence::default()); + if let Some(track) = s.track_mut(hidden) { + track.hidden = true; + track.playlist_mut().unwrap().clips.push(hidden_clip); + } + s.add_marker("Cue", MarkerKind::Marker, 2_500, 2_500); + s.add_marker("Selection", MarkerKind::Selection, 4_000, 4_500); + s.edit.grid_clips_markers = true; + + assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 2_500, 3_000, 4_000, 4_500]); + assert_eq!(s.grid_line_at_or_before(2_800), 2_500); + assert_eq!(s.snap_to_grid(2_800), 3_000); + assert_eq!(s.snap_to_grid(3_700), 4_000); + assert_eq!(s.grid_lines(2_400, 4_100, 2), [2_500, 4_000]); + assert_eq!(s.snap_to_grid(3_200), 3_000); + assert_eq!(s.snap_to_grid_excluding(2_800, &[clip_id]), 2_500); + + let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + s.track_mut(track).unwrap().folder = Some(folder); + s.track_mut(folder).unwrap().folder_open = false; + assert_eq!(s.grid_lines(0, 5_000, 20), [0, 2_500, 4_000, 4_500]); + } + + /// Open hidden folders keep their visible descendants eligible for lines and snapping. + #[test] + fn clips_markers_grid_keeps_visible_children_of_hidden_open_folders() { + let mut s = Session::default(); + let outer = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + let inner = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None); + let child = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let clip = Clip::midi(s.new_clip_id(), "Visible", 1_000, 2_000, crate::soundcraft_midi::Sequence::default()); + s.track_mut(inner).unwrap().folder = Some(outer); + s.track_mut(inner).unwrap().hidden = true; + s.track_mut(child).unwrap().folder = Some(inner); + s.track_mut(child).unwrap().playlist_mut().unwrap().clips.push(clip); + s.edit.grid_clips_markers = true; + + assert_eq!(s.grid_lines(0, 5_000, 20), [0, 1_000, 3_000]); + assert_eq!(s.snap_to_grid(2_800), 3_000); + assert_eq!(s.grid_line_at_or_before(2_800), 1_000); + + s.track_mut(child).unwrap().hidden = true; + assert_eq!(s.grid_lines(0, 5_000, 20), [0]); + assert_eq!(s.snap_to_grid(2_800), 2_800); + s.track_mut(child).unwrap().hidden = false; + s.track_mut(inner).unwrap().folder_open = false; + assert_eq!(s.grid_lines(0, 5_000, 20), [0]); + s.track_mut(inner).unwrap().folder_open = true; + s.track_mut(outer).unwrap().folder_open = false; + assert_eq!(s.grid_lines(0, 5_000, 20), [0]); + } + + /// With no real Clips/Markers boundaries, snapping leaves the position alone while drawing + /// retains the session-start line. + #[test] + fn empty_clips_markers_grid_does_not_snap_everything_to_zero() { + let mut s = Session::default(); + s.edit.grid_clips_markers = true; + assert_eq!(s.grid_lines(0, 1_000, 10), [0]); + assert_eq!(s.snap_to_grid(678), 678); + } + + /// Frame-based grids share the same references as their displayed counters. + #[test] + fn frame_grids_apply_session_counter_origins() { + let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps30, timecode_start: 1_600, ..Session::default() }; + s.edit.grid = GridValue::Timecode(crate::soundcraft_time::TimecodeValue::Second); + assert_eq!(s.grid_line_at_or_before(47_000), 46_400); + assert_eq!(s.snap_to_grid(46_400), 46_400); + + s.edit.grid = GridValue::FeetFrames(crate::soundcraft_time::FeetFramesValue::Foot); + s.edit.values.insert(FEET_FRAMES_START.into(), 1.0); + assert_eq!(s.grid_line_at_or_before(25_000), 24_000); + assert_eq!(s.snap_to_grid(24_000), 24_000); + } + + /// Every Feet+Frames increment shares the parser's single rounding at fractional frame rates. + #[test] + fn feet_frames_grid_matches_parsed_positions_with_signed_frame_origins() { + use crate::soundcraft_time::FeetFramesValue; + for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: rate, ..Session::default() }; + for origin in [-17, -1, 1, 17] { + s.edit.values.insert(FEET_FRAMES_START.into(), origin as f64); + for value in FeetFramesValue::ALL { + s.edit.grid = GridValue::FeetFrames(value); + for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] { + let at = s.parse_position(&format!("{feet}+00"), TimeFormat::FeetFrames).unwrap(); + assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?} {value:?} origin {origin}, foot {feet}"); + assert_eq!(s.snap_to_grid(at), at); + assert_eq!(s.grid_lines(at - 1, at + 2, 4), [at]); + } + } + } + } + let mut s = Session { sample_rate: SampleRate::HZ_48000, frame_rate: FrameRate::Fps29_97, ..Session::default() }; + s.edit.values.insert(FEET_FRAMES_START.into(), 1.0); + s.edit.grid = GridValue::FeetFrames(FeetFramesValue::Foot); + assert_eq!(s.snap_to_grid(49_649), 49_650); + assert_eq!(s.format_position(49_650, TimeFormat::FeetFrames), "2+00"); + } + + /// Drop-frame minute boundaries keep their counter alignment with signed session origins. + #[test] + fn drop_frame_grids_apply_session_origins_at_minute_boundaries() { + for (rate, minute_frame) in [(FrameRate::Fps29_97Drop, 1_800), (FrameRate::Fps59_94Drop, 3_600)] { + for origin_frame in [-1, 1] { + let sr = SampleRate::HZ_48000; + let origin = rate.samples_at_frame(origin_frame, sr); + let mut s = Session { sample_rate: sr, frame_rate: rate, timecode_start: origin, ..Session::default() }; + s.edit.grid = GridValue::Timecode(crate::soundcraft_time::TimecodeValue::Second); + for boundary in [-minute_frame, minute_frame] { + let at = rate.samples_at_frame(boundary, sr) - origin; + assert_eq!(s.grid_line_at_or_before(at), at, "{rate:?}, origin {origin_frame}"); + assert_eq!(s.snap_to_grid(at + 1), at, "{rate:?}, origin {origin_frame}"); + assert_eq!(s.grid_lines(at - 1, at + 2, 10), [at]); + } + } + } + } + + #[test] + fn session_with_legacy_fit_height_loads() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None); + if let Some(track) = s.track_mut(t) { + track.height = crate::TrackHeight::Large; + } + let text = s.to_json().unwrap().replace("\"height\": \"Large\"", "\"height\": \"Fit\""); + assert!(text.contains("\"height\": \"Fit\"")); + let back = Session::from_json(&text).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium)); + } + + /// Session JSON omits live flags and ignores their values in legacy documents. + #[test] + fn live_input_states_are_never_saved_or_restored() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let track = s.track_mut(t).unwrap(); + track.mixer.mute = true; + track.mixer.record_arm = true; + track.mixer.input_monitor = true; + + let mut value = serde_json::to_value(&s).unwrap(); + let mixer = value["tracks"][0]["mixer"].as_object_mut().unwrap(); + assert!(!mixer.contains_key("record_arm")); + assert!(!mixer.contains_key("input_monitor")); + + // Files written by older versions may still contain these keys; ignore them on input. + mixer.insert("record_arm".into(), serde_json::Value::Bool(true)); + mixer.insert("input_monitor".into(), serde_json::Value::Bool(true)); + let loaded = Session::from_json(&value.to_string()).unwrap(); + let track = loaded.track(t).unwrap(); + assert!(track.mixer.mute, "persistent mixer state still loads"); + assert!(!track.mixer.record_arm); + assert!(!track.mixer.input_monitor); + } + + #[test] + fn video_clip_round_trips_and_old_sessions_load() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Video, ChannelFormat::Mono, None); + let vid = SourceId(s.alloc()); + s.videos.push(VideoSource { + id: vid, + name: "cut.mov".into(), + path: "/films/cut.mov".into(), + width: 1920, + height: 1080, + frame_rate: 23.976, + duration: 12.5, + codec: "prores".into(), + offline: None, + }); + let cid = s.new_clip_id(); + if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) { + p.clips.push(Clip::video(cid, "cut", vid, 480, 0, 600_000)); + } + let back = Session::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!(back, s); + let c = &back.track(t).unwrap().clips()[0]; + assert_eq!((c.video_source(), c.source(), c.source_offset(), c.is_audio()), (Some(vid), None, 480, false)); + assert_eq!(back.track(t).unwrap().kind.label(), "Video Track"); + assert!(back.video(vid).is_some()); + // A session written before video support has no `videos` key. + let mut v = serde_json::to_value(Session::default()).unwrap(); + v.as_object_mut().unwrap().remove("videos"); + assert!(Session::from_json(&v.to_string()).unwrap().videos.is_empty()); + // Trimming the start keeps the picture anchored. + let mut c = c.clone(); + c.trim_start_to(100); + assert_eq!(c.source_offset(), 580); + } + + #[test] + fn unique_track_names_take_the_first_free_number() { + let mut s = Session::default(); + for name in ["Piano", "Piano 1", "Piano 3", "Piano 02", "Pianos 2"] { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name)); + } + assert_eq!(s.unique_track_name("Piano"), "Piano 2"); + assert_eq!(s.unique_track_name("Organ"), "Organ"); + assert_eq!(s.unique_track_name("Audio"), "Audio 1"); + } + + #[test] + fn names_from_a_file_are_cut_to_a_length_that_can_be_drawn() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.name = "é".repeat(1_000); + } + s.comments = "c".repeat(10_000); + let back = Session::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.name.chars().count()), Some(MAX_NAME_CHARS)); + assert_eq!(back.comments.len(), MAX_COMMENT_CHARS); + } + + #[test] + fn a_bad_sample_rate_or_tempo_is_repaired_on_load() { + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["sample_rate"] = serde_json::json!(0); + v["tempo"]["tempos"] = serde_json::json!([{"tick": 0, "bpm": 4e9}]); + let s = Session::from_json(&v.to_string()).unwrap(); + assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default())); + } + + #[test] + fn a_meter_change_far_beyond_the_position_range_is_dropped_on_load() { + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["tempo"]["meters"] = serde_json::json!([ + {"tick": 0, "numerator": 4, "denominator": 4}, + {"tick": i64::MAX, "numerator": 3, "denominator": 4} + ]); + let s = Session::from_json(&v.to_string()).unwrap(); + assert_eq!(s.parse_position("2|1|000", TimeFormat::BarsBeats), Ok(96_000)); + } + + #[test] + fn rejects_garbage_and_future_versions() { + assert!(Session::from_json("not json").is_err()); + assert!(Session::from_json("{}").is_err()); + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["format_version"] = serde_json::json!(999); + assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999)))); + } + + #[test] + fn the_version_is_checked_as_before() { + // #892: the version is read without building the whole document as a tree; the errors + // stay what they were. + let format = |t: &str| match Session::from_json(t) { + Err(SessionError::Format(m)) => m, + other => panic!("{t}: {other:?}"), + }; + for t in [ + "{}", + "[]", + "[5]", + " \n[1, {}]", + "\"x\"", + "5", + "null", + r#"{"format_version":"1"}"#, + r#"{"format_version":-1}"#, + r#"{"format_version":1.5}"#, + r#"{"format_version":null}"#, + r#"{"format_version":[0,0,0]}"#, + r#"{"name":"x","other":{"format_version":1}}"#, + ] { + assert_eq!(format(t), "missing format_version", "{t}"); + } + for t in ["not json", r#"{"format_version":1"#, r#"{"format_version":1} x"#, r#"{"format_version":"1", ]"#] { + let want = serde_json::from_str::(t).unwrap_err().to_string(); + assert_eq!(format(t), want, "{t}"); + } + let mut v = serde_json::to_value(Session::default()).unwrap(); + for (ver, want) in [(serde_json::json!(2), 2), (serde_json::json!(1u64 << 40), u32::MAX)] { + v["format_version"] = ver; + assert_eq!(Session::from_json(&v.to_string()), Err(SessionError::Version(want))); + } + // A field given twice is refused by name, the version too. + let twice = |key: &str| format(&format!(r#"{{"format_version":1,"{key}":"a","{key}":"b"}}"#)); + assert!(twice("name").starts_with("duplicate field `name` at line 1 column "), "{}", twice("name")); + assert!(twice("format_version").starts_with("duplicate field `format_version` at line 1 column "), "{}", twice("format_version")); + // A newer version is refused even when the rest would not parse. + assert_eq!(Session::from_json(r#"{"format_version":7,"tracks":5}"#), Err(SessionError::Version(7))); + // An older or the current version loads as it did. + for ver in [0, 1] { + v["format_version"] = serde_json::json!(ver); + assert_eq!(Session::from_json(&v.to_string()).map(|s| s.format_version), Ok(ver)); + } + } + + #[test] + fn duplicate_ids_are_rejected_on_load() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut v = serde_json::to_value(&s).unwrap(); + v["tracks"][1]["id"] = v["tracks"][0]["id"].clone(); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("duplicate track id")), "{err}"); + } + + #[test] + fn an_exhausted_id_space_is_rejected_on_load() { + let mut s = Session::default(); + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut v = serde_json::to_value(&s).unwrap(); + v["next_id"] = serde_json::json!(u64::MAX); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}"); + // A maximal existing id is exhausted too, however `next_id` was written: every later + // allocation would return `u64::MAX` again. + let mut v = serde_json::to_value(&s).unwrap(); + v["tracks"][0]["id"] = serde_json::json!(u64::MAX); + let err = Session::from_json(&v.to_string()).unwrap_err(); + assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}"); + } + + #[test] + fn sanitize_repairs() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.active_playlist = 7; + tr.mixer.volume_db = f32::NAN; + } + s.next_id = 0; + s.sanitize(); + let tr = s.track(t).unwrap(); + assert_eq!(tr.active_playlist, 0); + assert_eq!(tr.mixer.volume_db, 0.0); + assert!(s.next_id > t.0); + } + + #[test] + fn loaded_automation_is_clamped_to_its_range() { + // #884: a session file can carry points outside a lane's range; load clamps them. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tr = s.track_mut(t).unwrap(); + for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] { + tr.lane_mut(&p).points.push(crate::AutomationPoint { at: 0, value: 500.0 }); + } + let s = Session::from_json(&serde_json::to_string(&s).unwrap()).unwrap(); + let tr = s.track(t).unwrap(); + for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] { + assert_eq!(tr.lane(&p).unwrap().points[0].value, 12.0, "{p:?}"); + } + } + + #[test] + fn a_loaded_auto_name_scheme_leaves_room_for_its_number() { + let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() }; + scheme.sanitize(); + assert_eq!(scheme.name.chars().count() + scheme.suffix.chars().count(), crate::AutoName::MAX_TEXT_CHARS); + } +} diff --git a/crates/scraft-format/src/source.rs b/crates/scraft-format/src/source.rs new file mode 100644 index 000000000..7013d0031 --- /dev/null +++ b/crates/scraft-format/src/source.rs @@ -0,0 +1,204 @@ +//! Audio sources (files in the session's Audio Files folder) and their decoded data, and the +//! movies video clips reference. + +use crate::SourceId; +use craft_audio::{AudioBuffer, AudioInfo, CodecId, Container, peaks::Peaks}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// The kind of file a source came from, as session files store it in [`Source::format`]. The +/// variant names are part of the session format: craft-audio's `Container` and `CodecId` name +/// more formats under other names, so this stays here and maps from them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum FileFormat { + Wav, + Aiff, + Flac, + Mp3, + Ogg, + Aac, + Alac, + Caf, + /// Anything else that decodes: Matroska, WebM, raw AC-3. + Other, +} + +impl FileFormat { + /// The kind of the file `info` describes. + pub fn of(info: &AudioInfo) -> FileFormat { + match info.container { + Container::Wav => FileFormat::Wav, + Container::Aiff => FileFormat::Aiff, + Container::Flac => FileFormat::Flac, + Container::MpegAudio => FileFormat::Mp3, + Container::Ogg => FileFormat::Ogg, + Container::Caf => FileFormat::Caf, + Container::Adts => FileFormat::Aac, + // MP4 and QuickTime hold many codecs: the codec names the kind. + Container::Mp4 | Container::QuickTime => match info.codec { + CodecId::Alac => FileFormat::Alac, + CodecId::Aac | CodecId::HeAac => FileFormat::Aac, + _ => FileFormat::Other, + }, + _ => FileFormat::Other, + } + } +} + +/// Metadata for an audio file used by the session (serialised). +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Source { + pub id: SourceId, + pub name: String, + /// Path relative to the session folder (or absolute for referenced media). + pub path: String, + pub channels: u16, + pub frames: u64, + pub sample_rate: u32, + pub format: FileFormat, + /// Original BWF time stamp (samples), if any. + #[serde(default)] + pub time_reference: u64, + /// True when the file has not been written to disk yet (recorded/rendered this session). + #[serde(default)] + pub unsaved: bool, +} + +/// A movie used by Video track clips (serialised). The pictures themselves are never stored in +/// the session: the UI opens the movie from `path` and decodes frames on demand. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct VideoSource { + pub id: SourceId, + pub name: String, + /// Absolute path of the movie (referenced, never copied into the session folder). + pub path: String, + #[serde(default)] + pub width: u32, + #[serde(default)] + pub height: u32, + /// Frames per second (0 = unknown). + #[serde(default)] + pub frame_rate: f64, + /// Picture length in seconds. + #[serde(default)] + pub duration: f64, + /// Codec id (`h264`, `prores`, `mjpeg`, `hevc`, …). + #[serde(default)] + pub codec: String, + /// Why the movie is not opened, when its stored path was refused on opening the session. + #[serde(skip)] + pub offline: Option, +} + +impl VideoSource { + /// Display aspect ratio (width / height), 16:9 when unknown. + pub fn aspect(&self) -> f64 { + if self.width == 0 || self.height == 0 { 16.0 / 9.0 } else { f64::from(self.width) / f64::from(self.height) } + } +} + +/// Decoded audio plus waveform overviews (not serialised). +#[derive(Debug)] +pub struct SourceAudio { + pub buffer: AudioBuffer, + pub peaks: Vec, +} + +impl SourceAudio { + pub fn new(buffer: AudioBuffer) -> Self { + let peaks = buffer.channels.iter().map(|c| Peaks::build(c)).collect(); + SourceAudio { buffer, peaks } + } + pub fn frames(&self) -> usize { + self.buffer.frames() + } + pub fn channel(&self, ch: usize) -> Option<&[f32]> { + self.buffer.channels.get(ch).map(Vec::as_slice) + } +} + +/// Shared decoded audio, keyed by source id. +#[derive(Debug, Clone, Default)] +pub struct SourcePool { + map: BTreeMap>, +} + +impl SourcePool { + pub fn insert(&mut self, id: SourceId, audio: Arc) { + self.map.insert(id, audio); + } + pub fn get(&self, id: SourceId) -> Option<&Arc> { + self.map.get(&id) + } + pub fn remove(&mut self, id: SourceId) -> Option> { + self.map.remove(&id) + } + pub fn contains(&self, id: SourceId) -> bool { + self.map.contains_key(&id) + } + pub fn len(&self) -> usize { + self.map.len() + } + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + pub fn ids(&self) -> impl Iterator + '_ { + self.map.keys().copied() + } +} + +impl PartialEq for SourcePool { + fn eq(&self, other: &Self) -> bool { + self.map.len() == other.map.len() && self.map.iter().zip(other.map.iter()).all(|((a, x), (b, y))| a == b && Arc::ptr_eq(x, y)) + } +} + +#[cfg(test)] +mod tests { + use super::FileFormat; + use craft_audio::{AudioBuffer, EncodeFormat, EncodeOptions, encode, probe}; + + /// Sessions written before the format code moved to craft-audio name a source's format with + /// these words; they must keep reading, and new sessions must keep writing them. + #[test] + fn the_format_names_in_session_files_do_not_change() { + use FileFormat::{Aac, Aiff, Alac, Caf, Flac, Mp3, Ogg, Other, Wav}; + let names = ["Wav", "Aiff", "Flac", "Mp3", "Ogg", "Aac", "Alac", "Caf", "Other"]; + for (format, name) in [Wav, Aiff, Flac, Mp3, Ogg, Aac, Alac, Caf, Other].into_iter().zip(names) { + assert_eq!(serde_json::to_string(&format).unwrap(), format!("\"{name}\"")); + assert_eq!(serde_json::from_str::(&format!("\"{name}\"")).unwrap(), format); + } + } + + /// An MP4 is AAC only when its audio is: Opus, AC-3 or PCM in MP4 is not. + #[test] + fn an_mp4_is_named_by_its_codec() { + let wav = encode(&AudioBuffer::new(48_000, 1, 480), &EncodeOptions::default()).unwrap(); + let mut info = serde_json::to_value(probe(&wav, None).unwrap()).unwrap(); + for (container, codec, format) in [ + ("Mp4", "Aac", FileFormat::Aac), + ("Mp4", "HeAac", FileFormat::Aac), + ("QuickTime", "Alac", FileFormat::Alac), + ("Mp4", "Opus", FileFormat::Other), + ("Mp4", "Ac3", FileFormat::Other), + ("QuickTime", "Pcm", FileFormat::Other), + ("Adts", "Aac", FileFormat::Aac), + ] { + info["container"] = container.into(); + info["codec"] = codec.into(); + let info: craft_audio::AudioInfo = serde_json::from_value(info.clone()).unwrap(); + assert_eq!(FileFormat::of(&info), format, "{codec} in {container}"); + } + } + + #[test] + fn a_probed_file_maps_to_its_format() { + let buf = AudioBuffer::new(48_000, 1, 480); + for (written, format) in + [(EncodeFormat::Wav, FileFormat::Wav), (EncodeFormat::Aiff, FileFormat::Aiff), (EncodeFormat::Flac, FileFormat::Flac)] + { + let info = probe(&encode(&buf, &EncodeOptions::new(written)).unwrap(), None).unwrap(); + assert_eq!(FileFormat::of(&info), format); + } + } +} diff --git a/crates/scraft-format/src/track.rs b/crates/scraft-format/src/track.rs new file mode 100644 index 000000000..d31bc5912 --- /dev/null +++ b/crates/scraft-format/src/track.rs @@ -0,0 +1,477 @@ +//! Tracks and playlists. + +use crate::soundcraft_time::{Range, Samples}; +use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId}; + +/// Track types (Track › New). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +pub enum TrackKind { + #[default] + Audio, + Aux, + Master, + Midi, + Instrument, + Vca, + Folder, + /// Picture: clips reference a movie ([`crate::VideoSource`]); no audio path. + Video, +} + +impl TrackKind { + pub const ALL: [TrackKind; 8] = [ + TrackKind::Audio, + TrackKind::Aux, + TrackKind::Master, + TrackKind::Midi, + TrackKind::Instrument, + TrackKind::Vca, + TrackKind::Folder, + TrackKind::Video, + ]; + pub fn label(self) -> &'static str { + match self { + TrackKind::Audio => "Audio Track", + TrackKind::Aux => "Aux Input", + TrackKind::Master => "Master Fader", + TrackKind::Midi => "MIDI Track", + TrackKind::Instrument => "Instrument Track", + TrackKind::Vca => "VCA Master", + TrackKind::Folder => "Routing Folder Track", + TrackKind::Video => "Video Track", + } + } + pub fn default_name(self) -> &'static str { + match self { + TrackKind::Audio => "Audio", + TrackKind::Aux => "Aux", + TrackKind::Master => "Master", + TrackKind::Midi => "MIDI", + TrackKind::Instrument => "Inst", + TrackKind::Vca => "VCA", + TrackKind::Folder => "Folder", + TrackKind::Video => "Video", + } + } + pub fn id(self) -> &'static str { + match self { + TrackKind::Audio => "audio", + TrackKind::Aux => "aux", + TrackKind::Master => "master", + TrackKind::Midi => "midi", + TrackKind::Instrument => "instrument", + TrackKind::Vca => "vca", + TrackKind::Folder => "folder", + TrackKind::Video => "video", + } + } + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + TrackKind::ALL.into_iter().find(|k| k.id() == s || k.label().eq_ignore_ascii_case(&s)) + } + /// Track kinds that hold clips on a timeline. + pub fn has_playlist(self) -> bool { + matches!(self, TrackKind::Audio | TrackKind::Midi | TrackKind::Instrument | TrackKind::Video) + } + pub fn is_midi(self) -> bool { + matches!(self, TrackKind::Midi | TrackKind::Instrument) + } + /// Kinds that pass audio through the mixer. + pub fn is_audio_path(self) -> bool { + matches!(self, TrackKind::Audio | TrackKind::Aux | TrackKind::Master | TrackKind::Instrument | TrackKind::Folder) + } +} + +/// Track heights (View › Track Height presets). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)] +pub enum TrackHeight { + /// Any height from Micro to Extreme, in points. + Custom(u16), + Micro, + Mini, + Small, + #[default] + Medium, + Large, + Jumbo, + Extreme, + /// Fit To Window: this many points. + Fit(u16), +} + +/// A saved track height: `"Micro"` … `"Extreme"` (or `{"Micro": null}` …), `{"Custom": points}`, +/// `{"Fit": points}`, or the bare `"Fit"` older sessions used. Read as it comes, never buffered (as an +/// untagged enum would), so anything else in a session file is refused at its first token. +impl<'de> serde::Deserialize<'de> for TrackHeight { + fn deserialize>(d: D) -> Result { + d.deserialize_any(HeightVisitor) + } +} + +const HEIGHT_VARIANTS: &[&str] = &["Custom", "Micro", "Mini", "Small", "Medium", "Large", "Jumbo", "Extreme", "Fit"]; + +struct HeightVisitor; + +impl HeightVisitor { + /// The height a variant without a size names. + fn preset(name: &str) -> Option { + Some(match name { + "Micro" => TrackHeight::Micro, + "Mini" => TrackHeight::Mini, + "Small" => TrackHeight::Small, + "Medium" => TrackHeight::Medium, + "Large" => TrackHeight::Large, + "Jumbo" => TrackHeight::Jumbo, + "Extreme" => TrackHeight::Extreme, + _ => return None, + }) + } +} + +impl<'de> serde::de::Visitor<'de> for HeightVisitor { + type Value = TrackHeight; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a track height") + } + + fn visit_str(self, v: &str) -> Result { + match v { + // Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height. + "Fit" => Ok(TrackHeight::Medium), + "Custom" => Err(E::invalid_type(serde::de::Unexpected::UnitVariant, &"a height in points")), + other => Self::preset(other).ok_or_else(|| E::unknown_variant(other, HEIGHT_VARIANTS)), + } + } + + fn visit_map>(self, mut map: A) -> Result { + use serde::de::Error as _; + let Some(key) = map.next_key::()? else { return Err(A::Error::invalid_length(0, &"one variant")) }; + let h = match key.as_str() { + "Custom" => TrackHeight::Custom(map.next_value()?), + // `{"Fit": null}` is the old size-less Fit too, as above. + "Fit" => map.next_value::>()?.map_or(TrackHeight::Medium, TrackHeight::Fit), + other => { + let h = Self::preset(other).ok_or_else(|| A::Error::unknown_variant(other, HEIGHT_VARIANTS))?; + map.next_value::<()>()?; + h + } + }; + if map.next_key::()?.is_some() { + return Err(A::Error::invalid_length(2, &"one variant")); + } + Ok(h) + } +} + +impl TrackHeight { + pub const ALL: [TrackHeight; 7] = [ + TrackHeight::Micro, + TrackHeight::Mini, + TrackHeight::Small, + TrackHeight::Medium, + TrackHeight::Large, + TrackHeight::Jumbo, + TrackHeight::Extreme, + ]; + pub fn points(self) -> f32 { + match self { + TrackHeight::Custom(points) => f32::from(points).clamp(TrackHeight::Micro.points(), TrackHeight::Extreme.points()), + TrackHeight::Micro => 16.0, + TrackHeight::Mini => 24.0, + TrackHeight::Small => 46.0, + TrackHeight::Medium => 88.0, + TrackHeight::Large => 132.0, + TrackHeight::Jumbo => 220.0, + TrackHeight::Extreme => 440.0, + TrackHeight::Fit(points) => f32::from(points.max(16)), + } + } + pub fn label(self) -> &'static str { + match self { + TrackHeight::Custom(_) => "custom", + TrackHeight::Micro => "micro", + TrackHeight::Mini => "mini", + TrackHeight::Small => "small", + TrackHeight::Medium => "medium", + TrackHeight::Large => "large", + TrackHeight::Jumbo => "jumbo", + TrackHeight::Extreme => "extreme", + TrackHeight::Fit(_) => "fit to window", + } + } + pub fn from_id(s: &str) -> Option { + TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s)) + } + /// The preset this many points tall, else a custom height clamped to Micro..Extreme. + pub fn from_points(points: f32) -> TrackHeight { + let p = points.max(TrackHeight::Micro.points()).min(TrackHeight::Extreme.points()).round() as u16; + TrackHeight::ALL.into_iter().find(|h| h.points() == f32::from(p)).unwrap_or(TrackHeight::Custom(p)) + } + /// The next preset taller (or shorter) than this height; Extreme (or Micro) stays. + pub fn step(self, taller: bool) -> TrackHeight { + let p = self.points(); + let next = + if taller { TrackHeight::ALL.into_iter().find(|h| h.points() > p) } else { TrackHeight::ALL.into_iter().rev().find(|h| h.points() < p) }; + next.unwrap_or(self) + } +} + +/// The default track colour palette (original, chosen for SoundCraft). +pub const TRACK_COLORS: [[u8; 3]; 16] = [ + [86, 132, 214], + [94, 170, 214], + [80, 190, 168], + [104, 186, 98], + [168, 196, 76], + [222, 196, 72], + [230, 152, 62], + [222, 104, 72], + [214, 82, 110], + [196, 86, 168], + [150, 98, 206], + [110, 104, 214], + [130, 150, 170], + [178, 140, 110], + [120, 170, 140], + [190, 120, 150], +]; + +/// A playlist: an alternative take of a track's clips. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)] +pub struct Playlist { + pub name: String, + /// Clips sorted by start; non-overlapping except at crossfades. + pub clips: Vec, +} + +impl Playlist { + pub fn new(name: impl Into) -> Self { + Playlist { name: name.into(), clips: Vec::new() } + } + pub fn sort(&mut self) { + self.clips.sort_by_key(|c| (c.start, c.id.0)); + } + pub fn clip(&self, id: ClipId) -> Option<&Clip> { + self.clips.iter().find(|c| c.id == id) + } + pub fn clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> { + self.clips.iter_mut().find(|c| c.id == id) + } + pub fn clips_in(&self, r: Range) -> impl Iterator { + self.clips.iter().filter(move |c| c.range().overlaps(&r)) + } + pub fn clip_at(&self, at: Samples) -> Option<&Clip> { + self.clips.iter().rev().find(|c| c.range().contains(at)) + } + pub fn end(&self) -> Samples { + self.clips.iter().map(Clip::end).max().unwrap_or(0) + } +} + +/// A track. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Track { + pub id: TrackId, + pub name: String, + pub kind: TrackKind, + pub format: ChannelFormat, + pub color: [u8; 3], + pub height: TrackHeight, + pub playlists: Vec, + pub active_playlist: usize, + pub mixer: Mixer, + pub automation: Vec, + /// Which lane the track shows: `waveform`/`blocks`/`notes`/`regions` or an automation param id. + pub view: String, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub inactive: bool, + #[serde(default)] + pub frozen: bool, + #[serde(default)] + pub comments: String, + /// Folder that contains this track. + #[serde(default)] + pub folder: Option, + #[serde(default = "yes")] + pub folder_open: bool, + /// Instrument plugin (instrument tracks) — registry id. + #[serde(default)] + pub instrument: Option, + /// Ticks (musical) or samples timebase for clips. + #[serde(default)] + pub ticks_timebase: bool, + /// Elastic Audio algorithm, if enabled. + #[serde(default)] + pub elastic: Option, + /// Pinned to top in the Edit window. + #[serde(default)] + pub pinned: bool, +} + +fn yes() -> bool { + true +} + +impl Track { + pub fn new(id: TrackId, name: impl Into, kind: TrackKind, format: ChannelFormat, color: [u8; 3]) -> Self { + let name = name.into(); + let format = if kind.is_midi() && kind == TrackKind::Midi { ChannelFormat::Mono } else { format }; + let view = if kind.is_midi() { + "notes" + } else if kind.has_playlist() { + "waveform" + } else { + "volume" + }; + Track { + id, + playlists: vec![Playlist::new(name.clone())], + name, + kind, + format, + color, + height: TrackHeight::Medium, + active_playlist: 0, + mixer: Mixer::new(format), + automation: Vec::new(), + view: view.into(), + hidden: false, + inactive: false, + frozen: false, + comments: String::new(), + folder: None, + folder_open: true, + instrument: None, + ticks_timebase: kind.is_midi(), + elastic: None, + pinned: false, + } + } + + pub fn channels(&self) -> usize { + self.format.channels() + } + + pub fn playlist(&self) -> Option<&Playlist> { + self.playlists.get(self.active_playlist) + } + + pub fn playlist_mut(&mut self) -> Option<&mut Playlist> { + self.playlists.get_mut(self.active_playlist) + } + + pub fn clips(&self) -> &[Clip] { + self.playlist().map_or(&[], |p| p.clips.as_slice()) + } + + pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> { + self.automation.iter().find(|l| &l.param == p) + } + + pub fn lane_mut(&mut self, p: &AutoParam) -> &mut AutomationLane { + let idx = match self.automation.iter().position(|l| &l.param == p) { + Some(i) => i, + None => { + self.automation.push(AutomationLane::new(p.clone())); + self.automation.len() - 1 + } + }; + let len = self.automation.len(); + // `idx` is in range: it came from `position` or is the index of the element just pushed. + &mut self.automation[idx.min(len - 1)] + } + + pub fn is_folder(&self) -> bool { + self.kind == TrackKind::Folder + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_fit_string_loads_as_default_height() { + assert_eq!(serde_json::from_str::("\"Fit\"").unwrap(), TrackHeight::Medium); + assert_eq!(serde_json::from_str::("{\"Fit\":120}").unwrap(), TrackHeight::Fit(120)); + assert_eq!(serde_json::to_string(&TrackHeight::Fit(120)).unwrap(), "{\"Fit\":120}"); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + assert_eq!(serde_json::from_str::("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50)); + } + + #[test] + fn every_saved_height_form_loads_and_nothing_else() { + for h in TrackHeight::ALL.into_iter().chain([TrackHeight::Custom(50), TrackHeight::Fit(120)]) { + assert_eq!(serde_json::from_str::(&serde_json::to_string(&h).unwrap()).unwrap(), h); + assert_eq!(serde_json::from_value::(serde_json::to_value(h).unwrap()).unwrap(), h); + } + for h in TrackHeight::ALL { + let null = format!("{{{}:null}}", serde_json::to_string(&h).unwrap()); + assert_eq!(serde_json::from_str::(&null).unwrap(), h, "{null}"); + } + assert_eq!(serde_json::from_str::(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium); + assert!(serde_json::from_str::(r#"{"Custom":null}"#).is_err()); + for bad in + [r#""Custom""#, r#""Huge""#, "7", "[]", "null", "{}", r#"{"Fit":"x"}"#, r#"{"Fit":70000}"#, r#"{"Large":1}"#, r#"{"Fit":1,"Fit":2}"#] + { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } + } + + #[test] + fn a_huge_height_is_refused_at_its_first_token() { + // #892: the height was read through an untagged enum, which buffers the whole value + // first; a session could hold a huge array there. It is refused where it starts. + for (open, rest) in [("[", "0,".repeat(100_000) + "0]"), (r#"{"Fit":["#, "0,".repeat(100_000) + "0]}")] { + let text = format!("{open}{rest}"); + let err = serde_json::from_str::(&text).unwrap_err(); + assert!(err.is_data() && err.line() == 1 && err.column() <= open.len() + 1, "{open}: {err}"); + } + } + + #[test] + fn new_track_defaults() { + let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]); + assert_eq!(t.channels(), 2); + assert_eq!(t.view, "waveform"); + assert_eq!(t.playlists.len(), 1); + let m = Track::new(TrackId(2), "Keys", TrackKind::Midi, ChannelFormat::Stereo, TRACK_COLORS[0]); + assert_eq!(m.format, ChannelFormat::Mono); + assert_eq!(m.view, "notes"); + } + + #[test] + fn lane_mut_creates_once() { + let mut t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Mono, TRACK_COLORS[0]); + t.lane_mut(&AutoParam::Volume).set_point(0, -3.0); + t.lane_mut(&AutoParam::Volume).set_point(10, -6.0); + assert_eq!(t.automation.len(), 1); + assert_eq!(t.lane(&AutoParam::Volume).map(|l| l.points.len()), Some(2)); + } + + #[test] + fn kind_ids() { + for k in TrackKind::ALL { + assert_eq!(TrackKind::from_id(k.id()), Some(k)); + } + } + + #[test] + fn custom_heights_round_to_presets_and_step_through_them() { + assert_eq!(TrackHeight::from_points(88.0), TrackHeight::Medium); + assert_eq!(TrackHeight::from_points(57.4), TrackHeight::Custom(57)); + assert_eq!(TrackHeight::from_points(9999.0), TrackHeight::Extreme); + assert_eq!(TrackHeight::from_points(f32::NAN), TrackHeight::Micro); + assert_eq!(TrackHeight::Custom(u16::MAX).points(), 440.0); + assert_eq!(TrackHeight::Custom(57).step(true), TrackHeight::Medium); + assert_eq!(TrackHeight::Custom(57).step(false), TrackHeight::Small); + assert_eq!(TrackHeight::Extreme.step(true), TrackHeight::Extreme); + assert_eq!(TrackHeight::Micro.step(false), TrackHeight::Micro); + let json = serde_json::to_string(&TrackHeight::Custom(57)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), TrackHeight::Custom(57)); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + } +} diff --git a/crates/zip/src/archive.rs b/crates/zip/src/archive.rs index bdcf64cd7..1a4045c61 100644 --- a/crates/zip/src/archive.rs +++ b/crates/zip/src/archive.rs @@ -73,6 +73,12 @@ impl<'a> Archive<'a> { self.duplicates } + /// Do two entries' compressed data ranges overlap? A format that stores many files in one + /// archive can refuse aliases so one payload cannot be expanded and retained repeatedly. + pub fn has_overlapping_files(&mut self) -> Result { + self.zip.has_overlapping_files().map_err(|e| Error::NotZip(e.to_string())) + } + /// The entry names in the archive's order, folders included. A name that is not text is /// left out. pub fn names(&self) -> impl Iterator> { diff --git a/crates/zip/src/lib.rs b/crates/zip/src/lib.rs index 4e802d795..1ee218dcf 100644 --- a/crates/zip/src/lib.rs +++ b/crates/zip/src/lib.rs @@ -203,4 +203,20 @@ mod tests { assert_eq!(budget.remaining(), 0); assert!(budget.charge(u64::MAX).is_err()); } + + #[test] + #[allow(clippy::unwrap_used, clippy::expect_used)] + fn archive_reports_entries_that_alias_the_same_payload() { + use std::io::Write as _; + + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("first", opts).unwrap(); + zip.write_all(b"payload").unwrap(); + zip.shallow_copy_file("first", "alias").unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + + let mut archive = Archive::open(&bytes, Limits { max_entries: 2, max_total_bytes: 100 }).unwrap(); + assert!(archive.has_overlapping_files().unwrap()); + } } From a2e0f586323131ce54a8abae0603f0a26f00f23e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:20:55 +0000 Subject: [PATCH 005/118] Bound shared SoundCraft session IO --- crates/scraft-format/src/bundle.rs | 11 ++-- crates/scraft-format/src/lib.rs | 80 ++++++++++++++++++++++++++++-- crates/zip/src/archive.rs | 11 ++++ crates/zip/src/lib.rs | 19 +++++++ 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/crates/scraft-format/src/bundle.rs b/crates/scraft-format/src/bundle.rs index 52d6d33be..4421472a2 100644 --- a/crates/scraft-format/src/bundle.rs +++ b/crates/scraft-format/src/bundle.rs @@ -69,8 +69,7 @@ pub fn write_bundle(session: &Session, limits: BundleLimits) -> Result<(Vec, source.format = FileFormat::Wav; } - let text = fresh.to_json().map_err(|e| BundleError::Session(e.to_string()))?; - check_session_size("the session", text.len() as u64, limits.session_bytes, true)?; + let text = crate::write_session(&fresh, limits.session_bytes)?; account(&mut count, &mut total, text.len(), limits)?; let session_name = format!("{}.{}", sanitize_name(&fresh.name), SESSION_EXTENSION); zip.add(&session_name, text.as_bytes(), Compression::Stored).map_err(|e| BundleError::Invalid(e.to_string()))?; @@ -92,13 +91,11 @@ pub fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, HashMa if archive.has_overlapping_files().map_err(zip_error)? { return Err(BundleError::Invalid("files share data".into())); } - let names: Vec = archive.names().map(|n| n.into_owned()).collect(); - if names.len() != archive.len() { - return Err(BundleError::Invalid("an entry name is not text".into())); - } + let len = archive.len(); let mut session = None; let mut files = HashMap::new(); - for name in names { + for index in 0..len { + let name = archive.entry_name(index).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid("missing entry".into()))?; if name.ends_with('/') { continue; } diff --git a/crates/scraft-format/src/lib.rs b/crates/scraft-format/src/lib.rs index d26bca759..c54d7f934 100644 --- a/crates/scraft-format/src/lib.rs +++ b/crates/scraft-format/src/lib.rs @@ -42,12 +42,82 @@ pub const MAX_SESSION_BYTES: u64 = 256 << 20; /// Read a bare `.scraft` file after enforcing the caller's byte limit. pub fn read_session_bytes(name: &str, bytes: &[u8], limit: u64) -> Result { check_session_size(name, u64::try_from(bytes.len()).unwrap_or(u64::MAX), limit, false)?; - String::from_utf8(bytes.to_vec()).map_err(|_| BundleError::Session(format!("{name}: not a SoundCraft session"))) + read_session_data(name, bytes.to_vec(), limit) } -/// Serialize a `.scraft` session and refuse output larger than the caller's read limit. +/// Turn already-read `.scraft` bytes into text without making another copy. +pub fn read_session_data(name: &str, bytes: Vec, limit: u64) -> Result { + check_session_size(name, u64::try_from(bytes.len()).unwrap_or(u64::MAX), limit, false)?; + String::from_utf8(bytes).map_err(|_| BundleError::Session(format!("{name}: not a SoundCraft session"))) +} + +/// The size of pretty JSON for `session`, measured without retaining the serialized document. +pub fn session_json_len(session: &Session) -> Result { + struct Count(u64); + impl std::io::Write for Count { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut count = Count(0); + serde_json::to_writer_pretty(&mut count, session).map_err(|error| BundleError::Session(error.to_string()))?; + Ok(count.0) +} + +/// Serialize a `.scraft` session, refusing output larger than the caller's read limit while +/// keeping the temporary allocation within that limit. pub fn write_session(session: &Session, limit: u64) -> Result { - let text = session.to_json().map_err(|error| BundleError::Session(error.to_string()))?; - check_session_size("the session", u64::try_from(text.len()).unwrap_or(u64::MAX), limit, true)?; - Ok(text) + write_session_named(session, "the session", limit) +} + +/// [`write_session`] with the path or label used for the session-size error. +pub fn write_session_named(session: &Session, name: &str, limit: u64) -> Result { + struct Bounded(Vec, u64, bool); + impl std::io::Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let remaining = self.1.saturating_sub(u64::try_from(self.0.len()).unwrap_or(u64::MAX)); + let count = usize::try_from(remaining).unwrap_or(usize::MAX).min(bytes.len()); + self.0.extend_from_slice(&bytes[..count]); + self.2 |= count < bytes.len(); + Ok(count) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let capacity = usize::try_from(limit).unwrap_or(usize::MAX); + let mut output = Bounded(Vec::with_capacity(capacity.min(1024 * 1024)), limit, false); + let serialized = serde_json::to_writer_pretty(&mut output, session); + if output.2 { + check_session_size(name, limit.saturating_add(1), limit, true)?; + } + serialized.map_err(|error| BundleError::Session(error.to_string()))?; + String::from_utf8(output.0).map_err(|error| BundleError::Session(error.to_string())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod session_io_tests { + use super::*; + + #[test] + fn bounded_writer_matches_canonical_session_json_and_refuses_one_byte_under() { + let session = Session::default(); + let canonical = session.to_json().unwrap(); + let len = u64::try_from(canonical.len()).unwrap(); + assert_eq!(session_json_len(&session).unwrap(), len); + assert_eq!(write_session(&session, len).unwrap(), canonical); + assert!(write_session(&session, len - 1).is_err()); + } + + #[test] + fn bare_session_reader_checks_size_and_utf8() { + assert!(read_session_bytes("song.scraft", b"{}", 1).is_err()); + assert!(read_session_bytes("song.scraft", &[0xff], 10).is_err()); + assert_eq!(read_session_bytes("song.scraft", b"{}", 2).unwrap(), "{}"); + } } diff --git a/crates/zip/src/archive.rs b/crates/zip/src/archive.rs index 1a4045c61..1783b3e06 100644 --- a/crates/zip/src/archive.rs +++ b/crates/zip/src/archive.rs @@ -67,6 +67,17 @@ impl<'a> Archive<'a> { self.zip.is_empty() } + /// The name at `index`, copied on demand so callers can process hostile directories without + /// retaining a second copy of every potentially large name. + pub fn entry_name(&mut self, index: usize) -> Result, Error> { + if index >= self.zip.len() { + return Ok(None); + } + let file = self.zip.by_index_raw(index).map_err(|error| Error::NotZip(error.to_string()))?; + let name = file.name().map_err(|error| Error::NotZip(error.to_string()))?; + Ok(Some(name.into_owned())) + } + /// Do two entries share a name? `zip` then keeps one of them, and which one a writer meant /// is not known: a format that must not guess refuses the archive. pub fn has_duplicate_names(&self) -> bool { diff --git a/crates/zip/src/lib.rs b/crates/zip/src/lib.rs index 1ee218dcf..ef3264294 100644 --- a/crates/zip/src/lib.rs +++ b/crates/zip/src/lib.rs @@ -219,4 +219,23 @@ mod tests { let mut archive = Archive::open(&bytes, Limits { max_entries: 2, max_total_bytes: 100 }).unwrap(); assert!(archive.has_overlapping_files().unwrap()); } + + #[test] + #[allow(clippy::unwrap_used, clippy::expect_used)] + fn archive_can_copy_one_name_at_a_time_by_index() { + use std::io::Write as _; + + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + zip.start_file("first", opts).unwrap(); + zip.write_all(b"1").unwrap(); + zip.start_file("second", opts).unwrap(); + zip.write_all(b"2").unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + + let mut archive = Archive::open(&bytes, Limits { max_entries: 2, max_total_bytes: 10 }).unwrap(); + assert_eq!(archive.entry_name(0).unwrap().as_deref(), Some("first")); + assert_eq!(archive.entry_name(1).unwrap().as_deref(), Some("second")); + assert_eq!(archive.entry_name(2).unwrap(), None); + } } From 7c108201239d2504bb61e941828dc9f388187a94 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:26:31 +0000 Subject: [PATCH 006/118] Preserve ZIP directory semantics in bundle reader --- crates/scraft-format/src/bundle.rs | 5 +++-- crates/zip/src/archive.rs | 9 +++++++++ crates/zip/src/lib.rs | 7 +++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/scraft-format/src/bundle.rs b/crates/scraft-format/src/bundle.rs index 4421472a2..6b4885003 100644 --- a/crates/scraft-format/src/bundle.rs +++ b/crates/scraft-format/src/bundle.rs @@ -95,10 +95,11 @@ pub fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, HashMa let mut session = None; let mut files = HashMap::new(); for index in 0..len { - let name = archive.entry_name(index).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid("missing entry".into()))?; - if name.ends_with('/') { + let is_dir = archive.entry_is_dir(index).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid("missing entry".into()))?; + if is_dir { continue; } + let name = archive.entry_name(index).map_err(zip_error)?.ok_or_else(|| BundleError::Invalid("missing entry".into()))?; let is_session = !name.contains('/') && Path::new(&name).extension().and_then(OsStr::to_str) == Some(SESSION_EXTENSION); let size = archive.declared_size(&name).ok_or_else(|| BundleError::Invalid(format!("{name}: missing entry")))?; if is_session && session.is_none() { diff --git a/crates/zip/src/archive.rs b/crates/zip/src/archive.rs index 1783b3e06..83d7b603d 100644 --- a/crates/zip/src/archive.rs +++ b/crates/zip/src/archive.rs @@ -78,6 +78,15 @@ impl<'a> Archive<'a> { Ok(Some(name.into_owned())) } + /// Whether the entry at `index` is a directory according to ZIP filename conventions. + pub fn entry_is_dir(&mut self, index: usize) -> Result, Error> { + if index >= self.zip.len() { + return Ok(None); + } + let file = self.zip.by_index_raw(index).map_err(|error| Error::NotZip(error.to_string()))?; + Ok(Some(file.is_dir())) + } + /// Do two entries share a name? `zip` then keeps one of them, and which one a writer meant /// is not known: a format that must not guess refuses the archive. pub fn has_duplicate_names(&self) -> bool { diff --git a/crates/zip/src/lib.rs b/crates/zip/src/lib.rs index ef3264294..452351ee3 100644 --- a/crates/zip/src/lib.rs +++ b/crates/zip/src/lib.rs @@ -231,11 +231,14 @@ mod tests { zip.write_all(b"1").unwrap(); zip.start_file("second", opts).unwrap(); zip.write_all(b"2").unwrap(); + zip.start_file("folder\\", opts).unwrap(); let bytes = zip.finish().unwrap().into_inner(); - let mut archive = Archive::open(&bytes, Limits { max_entries: 2, max_total_bytes: 10 }).unwrap(); + let mut archive = Archive::open(&bytes, Limits { max_entries: 3, max_total_bytes: 10 }).unwrap(); assert_eq!(archive.entry_name(0).unwrap().as_deref(), Some("first")); assert_eq!(archive.entry_name(1).unwrap().as_deref(), Some("second")); - assert_eq!(archive.entry_name(2).unwrap(), None); + assert_eq!(archive.entry_is_dir(2).unwrap(), Some(true)); + assert_eq!(archive.entry_name(2).unwrap().as_deref(), Some("folder\\")); + assert_eq!(archive.entry_name(3).unwrap(), None); } } From 1df8d98c852f66eb0987ac156e8cdba07dfde20f Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 12 Nov 2026 20:24:38 +0100 Subject: [PATCH 007/118] craft-video: MPEG-2 encoder, first step: I and P pictures (#1133) The start of MPEG-2 encoding in craft_video::mpeg2 (feature mpeg2-encode): progressive frame pictures, closed I/P GOPs, 4:2:0 Main Profile and 4:2:2 Profile, constant quantiser or single-pass average bitrate with adaptive quantisation, half-sample motion search. The encoder shares the decoder's code tables, IDCT, dequantisation and motion compensation, so the decoder outputs exactly the encoder's reconstruction. B pictures, a VBV guarantee, interlaced coding and DVD or broadcast presets are later steps of #1133. The mpeg2 feature is split into mpeg2-decode and mpeg2-encode; mpeg2 still turns on both. --- crates/video/Cargo.toml | 10 +- crates/video/README.md | 2 +- crates/video/src/lib.rs | 4 +- crates/video/src/mpeg2/README.md | 70 ++- crates/video/src/mpeg2/encode/bitwriter.rs | 92 +++ crates/video/src/mpeg2/encode/mod.rs | 624 +++++++++++++++++++++ crates/video/src/mpeg2/encode/motion.rs | 191 +++++++ crates/video/src/mpeg2/encode/picture.rs | 412 ++++++++++++++ crates/video/src/mpeg2/encode/rate.rs | 102 ++++ crates/video/src/mpeg2/encode/tables.rs | 264 +++++++++ crates/video/src/mpeg2/encode/tests.rs | 250 +++++++++ crates/video/src/mpeg2/encode/transform.rs | 122 ++++ crates/video/src/mpeg2/idct.rs | 2 +- crates/video/src/mpeg2/mod.rs | 8 +- crates/video/src/mpeg2/slice.rs | 11 +- crates/video/src/mpeg2/vlc.rs | 2 +- crates/video/tests/mpeg2/encode.rs | 206 +++++++ crates/video/tests/mpeg2/main.rs | 2 + 18 files changed, 2361 insertions(+), 13 deletions(-) create mode 100644 crates/video/src/mpeg2/encode/bitwriter.rs create mode 100644 crates/video/src/mpeg2/encode/mod.rs create mode 100644 crates/video/src/mpeg2/encode/motion.rs create mode 100644 crates/video/src/mpeg2/encode/picture.rs create mode 100644 crates/video/src/mpeg2/encode/rate.rs create mode 100644 crates/video/src/mpeg2/encode/tables.rs create mode 100644 crates/video/src/mpeg2/encode/tests.rs create mode 100644 crates/video/src/mpeg2/encode/transform.rs create mode 100644 crates/video/tests/mpeg2/encode.rs diff --git a/crates/video/Cargo.toml b/crates/video/Cargo.toml index 0e31f8e26..3b95faef6 100644 --- a/crates/video/Cargo.toml +++ b/crates/video/Cargo.toml @@ -25,8 +25,12 @@ apv-encode = ["craft-media/bitstream"] vp8 = ["vp8-decode", "vp8-encode"] vp8-decode = [] vp8-encode = [] -# MPEG-2 video (ITU-T H.262) and MPEG-1 video (ISO/IEC 11172-2): decoder. -mpeg2 = [] +# MPEG-2 video (ITU-T H.262) and MPEG-1 video (ISO/IEC 11172-2): decoder (`mpeg2-decode`) and +# MPEG-2 encoder (`mpeg2-encode`). The encoder reuses the decoder's code tables, IDCT and motion +# compensation, so it turns on the decoder too. +mpeg2 = ["mpeg2-decode", "mpeg2-encode"] +mpeg2-decode = [] +mpeg2-encode = ["mpeg2-decode"] # MPEG-4 Part 2 video (ISO/IEC 14496-2, the Xvid/DivX format): Simple profile encoder. mpeg4 = ["mpeg4-encode"] mpeg4-encode = ["craft-media/bitstream"] @@ -130,7 +134,7 @@ required-features = ["mpeg4-encode"] [[test]] name = "mpeg2" -required-features = ["mpeg2"] +required-features = ["mpeg2-decode"] [[test]] name = "prores" diff --git a/crates/video/README.md b/crates/video/README.md index 4aff9b8d0..74b8c6414 100644 --- a/crates/video/README.md +++ b/crates/video/README.md @@ -21,7 +21,7 @@ No codec feature is on by default. | `dnx` | [`dnx`](src/dnx/README.md) | Avid DNxHD / DNxHR (SMPTE ST 2019-1 VC-3): decoder and encoder | | `apv` | [`apv`](src/apv/README.md) | APV (Advanced Professional Video, RFC 9924): decoder (`apv-decode` alone) and encoder (`apv-encode` alone) | | `vp8` | `vp8` | VP8 (RFC 6386): decoder | -| `mpeg2` | [`mpeg2`](src/mpeg2/README.md) | MPEG-2 video (ITU-T H.262) and MPEG-1 video (ISO/IEC 11172-2): decoder | +| `mpeg2` | [`mpeg2`](src/mpeg2/README.md) | MPEG-2 video (ITU-T H.262) and MPEG-1 video (ISO/IEC 11172-2): decoder (`mpeg2-decode` alone); MPEG-2 encoder with I and P pictures (`mpeg2-encode`, which also turns on the decoder) | | `mpeg4` | [`mpeg4`](src/mpeg4/README.md) | MPEG-4 Part 2 video (ISO/IEC 14496-2, the Xvid/DivX format): Simple profile encoder (`mpeg4-encode`) | | `prores` | `prores` | Apple ProRes (SMPTE RDD 36): decoder and encoder | | `h264` | [`h264`](src/h264/decode/README.md) | ITU-T H.264 / AVC: decoder (`h264-decode` alone) and encoder (`h264-encode` alone) | diff --git a/crates/video/src/lib.rs b/crates/video/src/lib.rs index e8e9d8113..d9723a81d 100644 --- a/crates/video/src/lib.rs +++ b/crates/video/src/lib.rs @@ -8,7 +8,7 @@ //! | `dnx` | [`dnx`] | Avid DNxHD / DNxHR (SMPTE ST 2019-1): decoder and encoder | //! | `apv` | [`apv`] | APV (RFC 9924): decoder (`apv-decode`) and encoder (`apv-encode`) | //! | `vp8` | [`vp8`] | VP8 (RFC 6386): decoder (`vp8-decode`) and key-frame encoder (`vp8-encode`) | -//! | `mpeg2` | [`mpeg2`] | MPEG-2 video (ITU-T H.262) and MPEG-1 video: decoder | +//! | `mpeg2` | [`mpeg2`] | MPEG-2 video (ITU-T H.262) and MPEG-1 video: decoder (`mpeg2-decode`) and MPEG-2 I/P encoder (`mpeg2-encode`) | //! | `mpeg4` | [`mpeg4`] | MPEG-4 Part 2 video (ISO/IEC 14496-2, Xvid/DivX): Simple profile encoder (`mpeg4-encode`) | //! | `prores` | [`prores`] | Apple ProRes (SMPTE RDD 36): decoder and encoder | //! | `h264` | [`h264`] | ITU-T H.264 / AVC: decoder (`h264-decode`) and encoder (`h264-encode`) | @@ -35,7 +35,7 @@ pub mod dnx; #[cfg(any(feature = "vp8-decode", feature = "vp8-encode"))] pub mod vp8; -#[cfg(feature = "mpeg2")] +#[cfg(feature = "mpeg2-decode")] pub mod mpeg2; #[cfg(feature = "mpeg4-encode")] diff --git a/crates/video/src/mpeg2/README.md b/crates/video/src/mpeg2/README.md index 1c49d45cd..b68dc800b 100644 --- a/crates/video/src/mpeg2/README.md +++ b/crates/video/src/mpeg2/README.md @@ -1,6 +1,7 @@ -# MPEG-2 and MPEG-1 video decoder (`craft_video::mpeg2`, feature `mpeg2`) +# MPEG-2 and MPEG-1 video decoder, MPEG-2 encoder (`craft_video::mpeg2`, features `mpeg2-decode`, `mpeg2-encode`) -Clean-room MPEG-2 video and MPEG-1 video decoder in pure Rust. Depends only on +Clean-room MPEG-2 video and MPEG-1 video decoder, and the first step of an MPEG-2 video encoder +(I and P pictures, see "Encoder"), in pure Rust. Depends only on `thiserror` and (optional, feature `threads`) `rayon`. Builds for `wasm32-unknown-unknown`; no `unsafe`. No GPL/LGPL code (FFmpeg, libmpeg2, libavcodec) was consulted; FFmpeg is used only as an external fixture generator and test oracle. @@ -11,6 +12,8 @@ consulted; FFmpeg is used only as an external fixture generator and test oracle. |---|---|---| | ITU-T H.262 \| ISO/IEC 13818-2 | 02/2000 (second edition, with Amd. 1 and Cor. 1/2 as incorporated in the 2000 text) | sequence / GOP / picture headers and extensions, slices, macroblocks, VLC tables (Annex B), inverse quantisation and mismatch control (§7.4), IDCT (Annex A), motion vectors and prediction (§7.6) | | ISO/IEC 11172-2 | 1993 | MPEG-1 video: slices spanning rows, MPEG-1 escape codes, oddification, full-pel vectors, D pictures | +| ITU-T H.262 \| ISO/IEC 13818-2 §8 | 02/2000 | profile and level limits the encoder chooses from (Tables 8-8 to 8-13) | +| MPEG-2 Test Model 5 (ISO/IEC JTC1/SC29/WG11/N0400) | April 1993 | encoder: target bit allocation and adaptive quantisation by activity (§5.2) | | IEEE Std 1180 | 1990 | IDCT accuracy test procedure (run as a unit test) | | ISO/IEC TR 13818-5 (MPEG Software Simulation Group decoder behaviour) | 1997 | two decoder behaviours the normative text leaves open (see "Interpretations") | @@ -72,6 +75,58 @@ the synthetic-stream tests confirmed against FFmpeg: formula only when the prediction is out of range: a field vector's doubled vertical component predicting a following frame vector. +## Encoder (feature `mpeg2-encode`) + +The encoder is the first step of MPEG-2 encoding in this crate, tracked in +storytold/craft-libs#1133. It writes valid MPEG-2 elementary streams, but not yet DVD or +broadcast deliverables: those need the later steps listed below. + +```rust +use craft_video::mpeg2::{Encoder, EncoderConfig, RateControl, YuvFrame}; + +let mut cfg = EncoderConfig::new(720, 576, 25, 1); +cfg.rate = RateControl::Abr { kbps: 6000 }; // or RateControl::Quantiser(1..=31) +cfg.gop_size = 12; // one I picture, then P pictures +let mut enc = Encoder::new(cfg)?; +for (i, f) in frames.iter().enumerate() { + let frame = YuvFrame { y: &f.y, cb: &f.cb, cr: &f.cr, y_stride: 720, c_stride: 360 }; + for p in enc.encode(&frame, i as i64)? { /* p.data, p.pts, p.dts, p.keyframe, p.picture_type */ } +} +for p in enc.flush() { /* none yet: no B pictures */ } +es.extend_from_slice(enc.end_of_sequence()); +``` + +What it does: + +- Main Profile (4:2:0) at Low, Main, High-1440 and High Level, and 4:2:2 Profile at Main and + High Level. The lowest level that holds the size, frame rate and (average) bitrate is chosen; + sizes beyond High Level are refused. Any even picture size; edges are padded by repetition. +- Progressive frame pictures, I and P, in closed GOPs. Every GOP starts with a sequence header, + sequence extension and GOP header (with a time code), so every I picture is a random access + point. Packets come out in decoding order, which equals display order without B pictures. +- Forward DCT on the transpose of the decoder's IDCT basis; intra quantisation with a small + dead zone, non-intra quantisation by truncation; default or custom quantiser matrices + (sequence header); linear or non-linear quantiser scale; intra DC precision 8-10 bits; + intra VLC table B.15. +- P pictures: integer pattern search (steps 8, 4, 2, 1) from candidate vectors (left neighbour, + predictor, the previous picture's vectors), then half-sample refinement; cost SAD + λ·bits. + Each macroblock is coded intra, motion-compensated with or without residual, "no MC" with + residual, or skipped. The search range sets f_code (1-4). +- Adaptive quantisation by macroblock activity (Test Model 5 §5.2.3). +- Rate control: a constant quantiser, or single-pass average bitrate with Test Model 5's + target bit allocation at picture level. Streams are signalled as variable bitrate + (`vbv_delay` 0xFFFF, the level's maximum bitrate and VBV size in the sequence header); the VBV + buffer is not modelled. +- The reconstruction uses the decoder's own dequantisation, mismatch control, IDCT and motion + compensation, so this crate's decoder outputs exactly the encoder's reconstruction. +- Slices (one per macroblock row) are coded in parallel on rayon with `threads`; the stream is + identical either way. + +Later steps (#1133): B pictures and reordering; CBR and VBR with a VBV guarantee; DVD presets +(sizes, aspect, GOP length, 9.8 Mb/s, 23.976 soft telecine); interlaced coding (field/frame DCT, +field prediction, alternate scan) and 4:2:2 High Level CBR for XDCAM HD422. MPEG-2 essence in the +MXF writer and a program stream muxer belong to `craft-media`. + ## Accuracy (vs FFmpeg as an external oracle) MPEG-2's IDCT is not bit-exact by definition (any IEEE 1180-accurate IDCT conforms, and the @@ -131,3 +186,14 @@ fell to 77–150 fps). These are lower bounds. 3000 random mutations never panic. - `synth_tests.rs`, `tests/mpeg2/oracle.rs`: see above (single-threaded decode identical to the threaded one). +- `encode/`: every encoder code decodes to its value with the decoder's tables; motion vector + deltas survive the decoder's wrap for f_code 1-4; forward/inverse DCT and quantiser round trips; + rate control converges on its target. `encode/tests.rs`: our decoder outputs exactly the + encoder's reconstruction (intra only, a 30-picture P GOP, 4:2:2, linear and non-linear + quantisers, quantisers 1 and 31, DC precision 8 and 10, custom matrices, odd macroblock counts, + average bitrate); quality and size follow the quantiser; repeated pictures skip; levels and + aspect in the sequence header; bad configurations and short or inconsistent planes are refused. +- `tests/mpeg2/encode.rs`: FFmpeg decodes our streams of `testsrc2` (CIF and 576-line 4:2:2) + without errors and within the decoder suite's tolerance of our reconstruction, ffprobe reports + the profile, level, pixel format and I/P picture types, and average bitrate lands within 10 % of + 800 and 2500 kbit/s targets. diff --git a/crates/video/src/mpeg2/encode/bitwriter.rs b/crates/video/src/mpeg2/encode/bitwriter.rs new file mode 100644 index 000000000..75b363d47 --- /dev/null +++ b/crates/video/src/mpeg2/encode/bitwriter.rs @@ -0,0 +1,92 @@ +//! Big-endian bit writer for start-code units. + +pub(crate) struct BitWriter { + out: Vec, + acc: u64, + /// Bits held in `acc` (always below 8 between calls). + n: u32, +} + +impl BitWriter { + pub fn new() -> Self { + Self { out: Vec::new(), acc: 0, n: 0 } + } + + /// Append the low `bits` (0..=32) bits of `v`, most significant first. + #[inline(always)] + pub fn put(&mut self, v: u32, bits: u32) { + if bits == 0 { + return; + } + let mask = if bits == 32 { u32::MAX } else { (1u32 << bits) - 1 }; + self.acc = (self.acc << bits) | u64::from(v & mask); + self.n += bits; + while self.n >= 8 { + self.n -= 8; + self.out.push((self.acc >> self.n) as u8); + } + self.acc &= (1u64 << self.n) - 1; + } + + #[inline(always)] + pub fn bit(&mut self, b: bool) { + self.put(b as u32, 1); + } + + /// Pad with zero bits to a byte boundary. + pub fn align(&mut self) { + if self.n > 0 { + self.put(0, 8 - self.n); + } + } + + /// Byte-align and write the start code `00 00 01 code`. + pub fn start_code(&mut self, code: u8) { + self.align(); + self.out.extend_from_slice(&[0, 0, 1, code]); + } + + /// Bits written so far. + #[cfg(test)] + pub fn bits(&self) -> u64 { + self.out.len() as u64 * 8 + u64::from(self.n) + } + + /// The bytes, padded to a byte boundary. + pub fn finish(mut self) -> Vec { + self.align(); + self.out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mpeg2::bits::Bits; + + #[test] + fn round_trips_through_the_reader() { + let mut w = BitWriter::new(); + w.put(0b101, 3); + w.put(0x1234_5678, 32); + w.bit(true); + w.put(0x3FF, 10); + assert_eq!(w.bits(), 46); + let d = w.finish(); + assert_eq!(d.len(), 6); + let mut b = Bits::new(&d); + assert_eq!(b.read(3), 0b101); + assert_eq!(b.read(32), 0x1234_5678); + assert!(b.bit()); + assert_eq!(b.read(10), 0x3FF); + assert_eq!(b.read(2), 0); + } + + #[test] + fn start_codes_are_byte_aligned() { + let mut w = BitWriter::new(); + w.put(1, 1); + w.start_code(0xB3); + assert_eq!(w.finish(), vec![0x80, 0, 0, 1, 0xB3]); + } +} diff --git a/crates/video/src/mpeg2/encode/mod.rs b/crates/video/src/mpeg2/encode/mod.rs new file mode 100644 index 000000000..fcd1bd8d1 --- /dev/null +++ b/crates/video/src/mpeg2/encode/mod.rs @@ -0,0 +1,624 @@ +//! MPEG-2 video encoder (ITU-T H.262 | ISO/IEC 13818-2), feature `mpeg2-encode`. +//! +//! This is the first step of MPEG-2 encoding in this crate (storytold/craft-libs#1133): I and P +//! pictures, progressive frame pictures, 4:2:0 (Main Profile) and 4:2:2 (4:2:2 Profile), closed +//! GOPs, constant quantiser or single-pass average bitrate. B pictures, a VBV guarantee, +//! interlaced coding and DVD / broadcast presets come later. +//! +//! ```no_run +//! use craft_video::mpeg2::encode::{Encoder, EncoderConfig, RateControl, YuvFrame}; +//! +//! let mut cfg = EncoderConfig::new(720, 576, 25, 1); +//! cfg.rate = RateControl::Abr { kbps: 6000 }; +//! let mut enc = Encoder::new(cfg)?; +//! let (y, c) = (vec![128u8; 720 * 576], vec![128u8; 360 * 288]); +//! let mut es = Vec::new(); +//! for i in 0..50 { +//! let frame = YuvFrame { y: &y, cb: &c, cr: &c, y_stride: 720, c_stride: 360 }; +//! for p in enc.encode(&frame, i)? { +//! es.extend_from_slice(&p.data); +//! } +//! } +//! for p in enc.flush() { +//! es.extend_from_slice(&p.data); +//! } +//! es.extend_from_slice(enc.end_of_sequence()); +//! # Ok::<(), craft_video::mpeg2::encode::EncodeError>(()) +//! ``` + +mod bitwriter; +mod motion; +mod picture; +mod rate; +mod tables; +mod transform; + +#[cfg(test)] +mod tests; + +use crate::mpeg2::ChromaFormat; +use crate::mpeg2::headers::{DEFAULT_INTRA, PictureType, SCAN}; +use crate::mpeg2::mc::Frame; + +use bitwriter::BitWriter; +use picture::{PicCtx, RowOut, RowPlanes, encode_row, qcode_for, qscale}; +use rate::RateCtl; + +#[derive(Debug, thiserror::Error)] +pub enum EncodeError { + #[error("unsupported MPEG-2 encoder configuration: {0}")] + Config(String), + #[error("bad input frame: {0}")] + BadFrame(String), +} + +/// aspect_ratio_information (H.262 Table 6-3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AspectRatio { + /// Square samples. + Square, + /// 4:3 display. + Dar4x3, + /// 16:9 display. + Dar16x9, + /// 2.21:1 display. + Dar221x100, +} + +impl AspectRatio { + fn code(self) -> u32 { + match self { + AspectRatio::Square => 1, + AspectRatio::Dar4x3 => 2, + AspectRatio::Dar16x9 => 3, + AspectRatio::Dar221x100 => 4, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RateControl { + /// A constant quantiser_scale_code (1..=31; mapped through H.262 Table 7-6 with + /// `nonlinear_quant`). Adaptive quantisation still varies it per macroblock when enabled. + Quantiser(u8), + /// Single-pass average bitrate in kbit/s. The stream is signalled as variable bitrate and + /// is not checked against the VBV buffer. + Abr { kbps: u32 }, +} + +#[derive(Clone, Debug)] +pub struct EncoderConfig { + /// Picture size: even, at most 1920×1152 (4:2:0) or 1920×1088 (4:2:2). + pub width: u32, + pub height: u32, + /// Frame rate: one of H.262 Table 6-4 (23.976, 24, 25, 29.97, 30, 50, 59.94, 60). + pub fps_num: u32, + pub fps_den: u32, + /// 4:2:0 (Main Profile) or 4:2:2 (4:2:2 Profile). + pub chroma: ChromaFormat, + pub aspect: AspectRatio, + /// Pictures per GOP: one I picture, then P pictures. 1 codes every picture as I. 1..=1023. + pub gop_size: u32, + pub rate: RateControl, + /// q_scale_type: the non-linear quantiser scale (finer steps at low quantisers). + pub nonlinear_quant: bool, + /// Vary the quantiser per macroblock with its activity (finer in flat areas). + pub adaptive_quant: bool, + /// Intra DC precision in bits: 8, 9 or 10. + pub intra_dc_bits: u8, + /// Quantiser matrices (raster order, values 1..=255); `None`: the defaults. + pub intra_matrix: Option<[u8; 64]>, + pub non_intra_matrix: Option<[u8; 64]>, + /// Largest motion vector searched, in luma samples (1..=63). + pub search_range: u32, + /// Code slices on the rayon pool (with the `threads` feature). + pub threads: bool, +} + +impl EncoderConfig { + pub fn new(width: u32, height: u32, fps_num: u32, fps_den: u32) -> Self { + Self { + width, + height, + fps_num, + fps_den, + chroma: ChromaFormat::Yuv420, + aspect: AspectRatio::Square, + gop_size: 12, + rate: RateControl::Quantiser(4), + nonlinear_quant: true, + adaptive_quant: true, + intra_dc_bits: 9, + intra_matrix: None, + non_intra_matrix: None, + search_range: 31, + threads: cfg!(feature = "threads"), + } + } +} + +/// Planar 8-bit input: luma `width × height`, chroma `width/2 × height/2` (4:2:0) or +/// `width/2 × height` (4:2:2), rows `y_stride` / `c_stride` bytes apart. +#[derive(Clone, Copy, Debug)] +pub struct YuvFrame<'a> { + pub y: &'a [u8], + pub cb: &'a [u8], + pub cr: &'a [u8], + pub y_stride: usize, + pub c_stride: usize, +} + +/// One coded picture, in decoding order. +#[derive(Clone, Debug)] +pub struct Packet { + /// Elementary stream bytes: the sequence header and GOP header before every I picture, then + /// the picture. + pub data: Vec, + pub pts: i64, + /// Decoding timestamp; equal to `pts` while there are no B pictures. + pub dts: i64, + /// An I picture starting a closed GOP (random access point). + pub keyframe: bool, + pub picture_type: PictureType, + /// Average quantiser_scale of the picture's macroblocks. + pub quantiser_scale: f32, +} + +/// The encoder's reconstruction of a picture (what a decoder outputs), cropped. +#[derive(Clone, Debug)] +pub struct ReconFrame { + pub width: u32, + pub height: u32, + pub y: Vec, + pub cb: Vec, + pub cr: Vec, +} + +/// The sequence_end_code. +const SEQUENCE_END: [u8; 4] = [0, 0, 1, 0xB7]; + +/// A profile and level (H.262 Tables 8-8 to 8-13): limits and profile_and_level_indication. +#[derive(Clone, Copy, Debug)] +struct Level { + indication: u8, + max_w: u32, + max_h: u32, + max_fps: u32, + luma_rate: u64, + bit_rate: u64, + vbv_bits: u64, +} + +const MAIN_LEVELS: [Level; 4] = [ + Level { indication: 0x4A, max_w: 352, max_h: 288, max_fps: 30, luma_rate: 3_041_280, bit_rate: 4_000_000, vbv_bits: 475_136 }, + Level { indication: 0x48, max_w: 720, max_h: 576, max_fps: 30, luma_rate: 10_368_000, bit_rate: 15_000_000, vbv_bits: 1_835_008 }, + Level { indication: 0x46, max_w: 1440, max_h: 1152, max_fps: 60, luma_rate: 47_001_600, bit_rate: 60_000_000, vbv_bits: 7_340_032 }, + Level { indication: 0x44, max_w: 1920, max_h: 1152, max_fps: 60, luma_rate: 62_668_800, bit_rate: 80_000_000, vbv_bits: 9_781_248 }, +]; + +const PROFILE_422_LEVELS: [Level; 2] = [ + Level { indication: 0x85, max_w: 720, max_h: 608, max_fps: 30, luma_rate: 11_059_200, bit_rate: 50_000_000, vbv_bits: 9_437_184 }, + Level { indication: 0x82, max_w: 1920, max_h: 1088, max_fps: 60, luma_rate: 62_668_800, bit_rate: 300_000_000, vbv_bits: 47_185_920 }, +]; + +/// frame_rate_code for an exact frame rate (Table 6-4). +fn frame_rate_code(num: u32, den: u32) -> Option { + if num == 0 || den == 0 { + return None; + } + let rates: [(u64, u64); 8] = [(24000, 1001), (24, 1), (25, 1), (30000, 1001), (30, 1), (50, 1), (60000, 1001), (60, 1)]; + rates.iter().position(|&(n, d)| u64::from(num) * d == n * u64::from(den)).map(|i| i as u32 + 1) +} + +/// The motion search's f_code: the smallest whose range [-8f, 8f-1] samples covers `range`. +fn f_code_for(range: u32) -> u8 { + (1..=4u8).find(|&fc| 8 * (1u32 << (fc - 1)) > range).unwrap_or(4) +} + +/// Pick the lowest level of the profile that holds the stream. +fn choose_level(cfg: &EncoderConfig) -> Result { + let levels: &[Level] = match cfg.chroma { + ChromaFormat::Yuv420 => &MAIN_LEVELS, + ChromaFormat::Yuv422 => &PROFILE_422_LEVELS, + ChromaFormat::Yuv444 => return Err(EncodeError::Config("4:4:4 has no MPEG-2 profile in use".into())), + }; + let fps = f64::from(cfg.fps_num) / f64::from(cfg.fps_den); + let luma_rate = f64::from(cfg.width) * f64::from(cfg.height) * fps; + let bps = match cfg.rate { + RateControl::Abr { kbps } => u64::from(kbps) * 1000, + RateControl::Quantiser(_) => 0, + }; + levels + .iter() + .find(|l| { + cfg.width <= l.max_w + && cfg.height <= l.max_h + && fps <= f64::from(l.max_fps) + 0.01 + && luma_rate <= l.luma_rate as f64 + && bps <= l.bit_rate + }) + .copied() + .ok_or_else(|| { + EncodeError::Config(format!( + "{}x{} at {:.3} fps{} is beyond every level of the profile", + cfg.width, + cfg.height, + fps, + if bps > 0 { format!(", {bps} bit/s") } else { String::new() } + )) + }) +} + +fn validate(cfg: &EncoderConfig) -> Result<(), EncodeError> { + let bad = |s: String| Err(EncodeError::Config(s)); + if cfg.width == 0 || cfg.height == 0 || !cfg.width.is_multiple_of(2) || !cfg.height.is_multiple_of(2) { + return bad(format!("picture size {}x{} must be even and non-zero", cfg.width, cfg.height)); + } + if frame_rate_code(cfg.fps_num, cfg.fps_den).is_none() { + return bad(format!("frame rate {}/{} is not one of H.262 Table 6-4", cfg.fps_num, cfg.fps_den)); + } + if !(1..=1023).contains(&cfg.gop_size) { + return bad(format!("GOP size {} (1..=1023)", cfg.gop_size)); + } + match cfg.rate { + RateControl::Quantiser(q) if !(1..=31).contains(&q) => return bad(format!("quantiser_scale_code {q} (1..=31)")), + RateControl::Abr { kbps: 0 } => return bad("bitrate 0".into()), + _ => {} + } + if !(8..=10).contains(&cfg.intra_dc_bits) { + return bad(format!("intra DC precision {} bits (8..=10)", cfg.intra_dc_bits)); + } + if [cfg.intra_matrix, cfg.non_intra_matrix].iter().flatten().any(|m| m.contains(&0)) { + return bad("quantiser matrix with a zero entry".into()); + } + if !(1..=63).contains(&cfg.search_range) { + return bad(format!("search range {} (1..=63)", cfg.search_range)); + } + Ok(()) +} + +/// Write a quantiser matrix (raster order) in zigzag order. +fn put_matrix(w: &mut BitWriter, m: &[u8; 64]) { + for &pos in &SCAN[0] { + w.put(u32::from(m[usize::from(pos)]), 8); + } +} + +/// A stateful encoder. Feed pictures in display order; packets come out in decoding order. +pub struct Encoder { + cfg: EncoderConfig, + level: Level, + mb_w: usize, + mb_h: usize, + shx: u32, + shy: u32, + f_code: u8, + intra_matrix: [u8; 64], + inter_matrix: [u8; 64], + /// Sequence header and sequence extension, sent before every GOP. + seq_header: Vec, + /// The reconstruction of the previous picture. + reference: Option, + prev_mvs: Vec<[i32; 2]>, + /// Pictures coded so far. + count: u64, + /// Position of the next picture in its GOP. + gop_pos: u32, + rc: Option, + recon_capture: bool, + recon: Vec, +} + +impl Encoder { + pub fn new(cfg: EncoderConfig) -> Result { + validate(&cfg)?; + let level = choose_level(&cfg)?; + let (shx, shy) = if cfg.chroma == ChromaFormat::Yuv422 { (1, 0) } else { (1, 1) }; + let nonlinear = cfg.nonlinear_quant; + let q_range = (f64::from(qscale(nonlinear, 1)), f64::from(qscale(nonlinear, 31))); + let rc = match cfg.rate { + RateControl::Abr { kbps } => Some(RateCtl::new(f64::from(kbps) * 1000.0, f64::from(cfg.fps_num) / f64::from(cfg.fps_den), q_range)), + RateControl::Quantiser(_) => None, + }; + let mut enc = Encoder { + level, + mb_w: (cfg.width as usize).div_ceil(16), + mb_h: (cfg.height as usize).div_ceil(16), + shx, + shy, + f_code: f_code_for(cfg.search_range), + intra_matrix: cfg.intra_matrix.unwrap_or(DEFAULT_INTRA), + inter_matrix: cfg.non_intra_matrix.unwrap_or([16; 64]), + seq_header: Vec::new(), + reference: None, + prev_mvs: Vec::new(), + count: 0, + gop_pos: 0, + rc, + recon_capture: false, + recon: Vec::new(), + cfg, + }; + enc.seq_header = enc.sequence_header_bytes(); + Ok(enc) + } + + pub fn config(&self) -> &EncoderConfig { + &self.cfg + } + + /// profile_and_level_indication of the stream (e.g. 0x48 Main Profile @ Main Level). + pub fn profile_and_level(&self) -> u8 { + self.level.indication + } + + /// The sequence header and sequence extension (for containers that store them apart). + pub fn sequence_header(&self) -> &[u8] { + &self.seq_header + } + + /// The sequence_end_code to append after the last packet of an elementary stream. + pub fn end_of_sequence(&self) -> &'static [u8] { + &SEQUENCE_END + } + + /// Keep each picture's reconstruction for [`Encoder::take_recon`]. + pub fn set_recon_capture(&mut self, on: bool) { + self.recon_capture = on; + } + + pub fn take_recon(&mut self) -> Vec { + std::mem::take(&mut self.recon) + } + + /// Pictures held for reordering (none yet: there are no B pictures). + pub fn flush(&mut self) -> Vec { + Vec::new() + } + + fn sequence_header_bytes(&self) -> Vec { + let c = &self.cfg; + let mut w = BitWriter::new(); + let bit_rate = self.level.bit_rate.div_ceil(400); + let vbv = self.level.vbv_bits / 16384; + w.start_code(0xB3); + w.put(c.width & 0xFFF, 12); + w.put(c.height & 0xFFF, 12); + w.put(c.aspect.code(), 4); + w.put(frame_rate_code(c.fps_num, c.fps_den).unwrap_or(3), 4); + w.put((bit_rate & 0x3FFFF) as u32, 18); + w.bit(true); // marker_bit + w.put((vbv & 0x3FF) as u32, 10); + w.bit(false); // constrained_parameters_flag + for m in [&c.intra_matrix, &c.non_intra_matrix] { + w.bit(m.is_some()); + if let Some(m) = m { + put_matrix(&mut w, m); + } + } + w.start_code(0xB5); + w.put(1, 4); // sequence extension + w.put(u32::from(self.level.indication), 8); + w.bit(true); // progressive_sequence + w.put(if c.chroma == ChromaFormat::Yuv422 { 2 } else { 1 }, 2); + w.put(c.width >> 12, 2); + w.put(c.height >> 12, 2); + w.put((bit_rate >> 18) as u32 & 0xFFF, 12); + w.bit(true); // marker_bit + w.put((vbv >> 10) as u32 & 0xFF, 8); + w.bit(true); // low_delay: no B pictures + w.put(0, 2); // frame_rate_extension_n + w.put(0, 5); // frame_rate_extension_d + w.finish() + } + + /// group_of_pictures_header with the time code of picture `self.count`. + fn gop_header(&self, w: &mut BitWriter) { + let fps = u64::from(self.cfg.fps_num.div_ceil(self.cfg.fps_den)).max(1); + let n = self.count; + let secs = n / fps; + w.start_code(0xB8); + w.bit(false); // drop_frame_flag + w.put(((secs / 3600) % 24) as u32, 5); + w.put(((secs / 60) % 60) as u32, 6); + w.bit(true); // marker_bit + w.put((secs % 60) as u32, 6); + w.put((n % fps) as u32, 6); + w.bit(true); // closed_gop + w.bit(false); // broken_link + } + + fn picture_header(&self, w: &mut BitWriter, intra: bool) { + w.start_code(0x00); + w.put(self.gop_pos & 0x3FF, 10); // temporal_reference: display order within the GOP + w.put(if intra { 1 } else { 2 }, 3); + w.put(0xFFFF, 16); // vbv_delay: variable bitrate + if !intra { + w.bit(false); // full_pel_forward_vector + w.put(7, 3); // forward_f_code (MPEG-2: in the extension) + } + w.bit(false); // extra_bit_picture + w.start_code(0xB5); + w.put(8, 4); // picture coding extension + let fc = if intra { 15 } else { u32::from(self.f_code) }; + for v in [fc, fc, 15, 15] { + w.put(v, 4); + } + w.put(u32::from(self.cfg.intra_dc_bits - 8), 2); + w.put(3, 2); // frame picture + w.bit(false); // top_field_first + w.bit(true); // frame_pred_frame_dct + w.bit(false); // concealment_motion_vectors + w.bit(self.cfg.nonlinear_quant); // q_scale_type + w.bit(true); // intra_vlc_format + w.bit(false); // alternate_scan + w.bit(false); // repeat_first_field + w.bit(self.cfg.chroma == ChromaFormat::Yuv420); // chroma_420_type = progressive_frame + w.bit(true); // progressive_frame + w.bit(false); // composite_display_flag + } + + /// Copy the input into a macroblock-aligned frame, repeating the last column and row. + fn load(&self, f: &YuvFrame) -> Result { + let (w, h) = (self.cfg.width as usize, self.cfg.height as usize); + let (cw, ch) = (w >> self.shx, h >> self.shy); + let (pw, ph) = (self.mb_w * 16, self.mb_h * 16); + let check = |name: &str, plane: &[u8], stride: usize, pw: usize, ph: usize| { + let need = stride.checked_mul(ph - 1).and_then(|n| n.checked_add(pw)); + if stride < pw || need.is_none_or(|n| plane.len() < n) { + return Err(EncodeError::BadFrame(format!("{name}: {} bytes with stride {stride} for {pw}x{ph}", plane.len()))); + } + Ok(()) + }; + check("luma", f.y, f.y_stride, w, h)?; + check("Cb", f.cb, f.c_stride, cw, ch)?; + check("Cr", f.cr, f.c_stride, cw, ch)?; + let mut out = Frame::new(pw, ph, pw >> self.shx, ph >> self.shy); + for (k, (src, stride, sw, sh)) in [(f.y, f.y_stride, w, h), (f.cb, f.c_stride, cw, ch), (f.cr, f.c_stride, cw, ch)].into_iter().enumerate() { + let dw = if k == 0 { pw } else { out.cw }; + let dh = if k == 0 { ph } else { out.ch }; + let dst = &mut out.planes[k]; + for r in 0..dh { + let s = &src[r.min(sh - 1) * stride..][..sw]; + let d = &mut dst[r * dw..(r + 1) * dw]; + d[..sw].copy_from_slice(s); + let last = s[sw - 1]; + d[sw..].fill(last); + } + } + Ok(out) + } + + /// quantiser_scale_code per macroblock: `base` scaled by each macroblock's activity + /// (H.262 Test Model 5 §5.2.3) with adaptive quantisation. + fn mb_quant(&self, src: &Frame, base: f64) -> Vec { + let n = self.mb_w * self.mb_h; + let nl = self.cfg.nonlinear_quant; + if !self.cfg.adaptive_quant { + return vec![qcode_for(nl, base); n]; + } + let acts: Vec = (0..n).map(|a| activity(src, a % self.mb_w, a / self.mb_w)).collect(); + let avg = acts.iter().sum::() / n.max(1) as f64; + acts.iter().map(|&a| qcode_for(nl, base * (2.0 * a + avg) / (a + 2.0 * avg))).collect() + } + + /// Encode one picture. Returns the packets that became ready (one per picture for now). + pub fn encode(&mut self, frame: &YuvFrame, pts: i64) -> Result, EncodeError> { + let src = self.load(frame)?; + let reference = if self.gop_pos == 0 { None } else { self.reference.take() }; + let intra = reference.is_none(); + if intra { + self.gop_pos = 0; + if let Some(rc) = self.rc.as_mut() { + rc.start_gop(self.cfg.gop_size); + } + } + let base = match (&self.rc, self.cfg.rate) { + (Some(rc), _) => rc.qscale(intra), + (None, RateControl::Quantiser(q)) => f64::from(qscale(self.cfg.nonlinear_quant, q)), + (None, _) => 8.0, + }; + let mb_qcode = self.mb_quant(&src, base); + let mut recon = Frame::new(src.w, src.h, src.cw, src.ch); + let ctx = PicCtx { + src: &src, + reference: reference.as_ref(), + prev_mvs: &self.prev_mvs, + mb_w: self.mb_w, + shx: self.shx, + shy: self.shy, + intra_matrix: &self.intra_matrix, + inter_matrix: &self.inter_matrix, + nonlinear: self.cfg.nonlinear_quant, + dc_prec: self.cfg.intra_dc_bits - 8, + f_code: self.f_code, + search: self.cfg.search_range as i32, + mb_qcode: &mb_qcode, + }; + let rows = encode_rows(&ctx, &mut recon, self.cfg.threads); + let mut w = BitWriter::new(); + if intra { + self.gop_header(&mut w); + } + self.picture_header(&mut w, intra); + let mut data = if intra { self.seq_header.clone() } else { Vec::new() }; + data.extend_from_slice(&w.finish()); + let mut qsum = 0u64; + let mut mvs = Vec::with_capacity(self.mb_w * self.mb_h); + for r in rows { + data.extend_from_slice(&r.bytes); + qsum += r.stats.qsum; + mvs.extend_from_slice(&r.mvs); + } + let avg_q = qsum as f64 / (self.mb_w * self.mb_h).max(1) as f64; + if let Some(rc) = self.rc.as_mut() { + rc.update(intra, data.len() as u64 * 8, avg_q); + } + if self.recon_capture { + self.recon.push(self.crop(&recon)); + } + self.reference = Some(recon); + self.prev_mvs = mvs; + self.count += 1; + self.gop_pos = (self.gop_pos + 1) % self.cfg.gop_size; + let picture_type = if intra { PictureType::I } else { PictureType::P }; + Ok(vec![Packet { data, pts, dts: pts, keyframe: intra, picture_type, quantiser_scale: avg_q as f32 }]) + } + + fn crop(&self, f: &Frame) -> ReconFrame { + let (w, h) = (self.cfg.width as usize, self.cfg.height as usize); + let (cw, ch) = (w >> self.shx, h >> self.shy); + let crop = |p: &[u8], stride: usize, w: usize, h: usize| (0..h).flat_map(|r| p[r * stride..r * stride + w].iter().copied()).collect(); + ReconFrame { + width: w as u32, + height: h as u32, + y: crop(&f.planes[0], f.w, w, h), + cb: crop(&f.planes[1], f.cw, cw, ch), + cr: crop(&f.planes[2], f.cw, cw, ch), + } + } +} + +/// Macroblock activity: one plus the smallest variance of its four luma blocks (TM5). +fn activity(f: &Frame, mbx: usize, mby: usize) -> f64 { + let mut min = f64::MAX; + for b in 0..4 { + let (x0, y0) = (mbx * 16 + (b & 1) * 8, mby * 16 + (b >> 1) * 8); + let (mut s, mut s2) = (0u64, 0u64); + for r in 0..8 { + for &v in &f.planes[0][(y0 + r) * f.w + x0..][..8] { + s += u64::from(v); + s2 += u64::from(v) * u64::from(v); + } + } + let mean = s as f64 / 64.0; + min = min.min(s2 as f64 / 64.0 - mean * mean); + } + 1.0 + min.max(0.0) +} + +/// Code every macroblock row of the picture into `recon`, in parallel when allowed. +fn encode_rows(ctx: &PicCtx, recon: &mut Frame, threads: bool) -> Vec { + let (w, cw) = (recon.w, recon.cw); + let clines = 16 >> ctx.shy; + let [y, cb, cr] = &mut recon.planes; + let run = |r: usize, y: &mut [u8], cb: &mut [u8], cr: &mut [u8]| encode_row(ctx, r, &mut RowPlanes { y, cb, cr }); + #[cfg(feature = "threads")] + if threads { + use rayon::prelude::*; + return y + .par_chunks_mut(16 * w) + .zip(cb.par_chunks_mut(clines * cw)) + .zip(cr.par_chunks_mut(clines * cw)) + .enumerate() + .map(|(r, ((y, cb), cr))| run(r, y, cb, cr)) + .collect(); + } + let _ = threads; + y.chunks_mut(16 * w) + .zip(cb.chunks_mut(clines * cw)) + .zip(cr.chunks_mut(clines * cw)) + .enumerate() + .map(|(r, ((y, cb), cr))| run(r, y, cb, cr)) + .collect() +} diff --git a/crates/video/src/mpeg2/encode/motion.rs b/crates/video/src/mpeg2/encode/motion.rs new file mode 100644 index 000000000..a7bedc80b --- /dev/null +++ b/crates/video/src/mpeg2/encode/motion.rs @@ -0,0 +1,191 @@ +//! Motion search for frame prediction: integer pattern search from predictor candidates, then +//! half-sample refinement. Costs are SAD + λ·(motion vector bits). + +use crate::mpeg2::mc::{Block, Frame, predict}; + +use super::tables::{enc_tables, wrap_delta}; + +pub(crate) struct Search<'a> { + pub reference: &'a Frame, + /// The source macroblock's luma (16×16). + pub src: &'a [u8; 256], + pub mbx: usize, + pub mby: usize, + pub f_code: u8, + /// Largest integer displacement searched, in samples. + pub range: i32, + /// Motion vector predictor (half-sample units). + pub pmv: [i32; 2], + pub lambda: u32, +} + +/// The chosen vector (half-sample units) and its SAD. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Found { + pub mv: [i32; 2], + pub sad: u32, +} + +/// Steps of the integer pattern search, coarse to fine. +const STEPS: [i32; 4] = [8, 4, 2, 1]; +/// Moves per step before moving on (bounds the search on pathological content). +const MAX_MOVES: usize = 16; + +impl Search<'_> { + fn origin(&self) -> (i32, i32) { + ((self.mbx * 16) as i32, (self.mby * 16) as i32) + } + + /// Integer displacements allowed per axis: inside the reference and the f_code range. + fn bounds(&self) -> ([i32; 2], [i32; 2]) { + let f = 1i32 << self.f_code.saturating_sub(1); + let (ox, oy) = self.origin(); + let (w, h) = (self.reference.w as i32, self.reference.h as i32); + let lo_lim = -(8 * f).min(self.range); + let hi_lim = (8 * f - 1).min(self.range); + ([lo_lim.max(-ox), lo_lim.max(-oy)], [hi_lim.min(w - 16 - ox), hi_lim.min(h - 16 - oy)]) + } + + /// A half-sample vector the bitstream can carry whose prediction stays inside the reference. + fn valid(&self, mv: [i32; 2]) -> bool { + let f = 1i32 << self.f_code.saturating_sub(1); + let (ox, oy) = self.origin(); + let size = [self.reference.w as i32, self.reference.h as i32]; + let o = [ox, oy]; + (0..2).all(|c| { + let start = o[c] + (mv[c] >> 1); + mv[c] >= -16 * f && mv[c] < 16 * f && start >= 0 && start + 16 + (mv[c] & 1) <= size[c] + }) + } + + fn mv_cost(&self, mv: [i32; 2]) -> u32 { + let t = enc_tables(); + let bits: u32 = (0..2).map(|c| t.motion_bits(wrap_delta(mv[c], self.pmv[c], self.f_code), self.f_code)).sum(); + self.lambda * bits + } + + fn sad_int(&self, d: [i32; 2]) -> u32 { + let (ox, oy) = self.origin(); + let stride = self.reference.w; + let x = (ox + d[0]) as usize; + let y = (oy + d[1]) as usize; + let plane = &self.reference.planes[0]; + let mut sad = 0u32; + for r in 0..16 { + let at = (y + r) * stride + x; + let Some(row) = plane.get(at..at + 16) else { return u32::MAX }; + for (a, b) in row.iter().zip(&self.src[r * 16..r * 16 + 16]) { + sad += u32::from(a.abs_diff(*b)); + } + } + sad + } + + fn sad_half(&self, mv: [i32; 2]) -> u32 { + let (ox, oy) = self.origin(); + let mut tmp = [0u8; 256]; + let blk = Block { x: ox + (mv[0] >> 1), y: oy + (mv[1] >> 1), hx: mv[0] & 1 != 0, hy: mv[1] & 1 != 0, bw: 16, bh: 16 }; + predict(&mut tmp, 0, 16, self.reference.plane(0, None), blk, false); + tmp.iter().zip(self.src.iter()).map(|(a, b)| u32::from(a.abs_diff(*b))).sum() + } + + /// Best vector among the candidates (half-sample units), refined. + pub fn run(&self, candidates: &[[i32; 2]]) -> Found { + let (lo, hi) = self.bounds(); + let clamp = |d: [i32; 2]| [d[0].clamp(lo[0], hi[0].max(lo[0])), d[1].clamp(lo[1], hi[1].max(lo[1]))]; + let eval = |d: [i32; 2]| { + let sad = self.sad_int(d); + (sad.saturating_add(self.mv_cost([2 * d[0], 2 * d[1]])), sad) + }; + let mut best_d = [0, 0]; + let (mut best_cost, mut best_sad) = eval(best_d); + for c in candidates { + let d = clamp([c[0] >> 1, c[1] >> 1]); + if d != best_d { + let (cost, sad) = eval(d); + if cost < best_cost { + (best_d, best_cost, best_sad) = (d, cost, sad); + } + } + } + for step in STEPS.into_iter().filter(|&s| s <= self.range.max(1)) { + for _ in 0..MAX_MOVES { + let mut moved = false; + for off in [[step, 0], [-step, 0], [0, step], [0, -step]] { + let d = clamp([best_d[0] + off[0], best_d[1] + off[1]]); + if d == best_d { + continue; + } + let (cost, sad) = eval(d); + if cost < best_cost { + (best_d, best_cost, best_sad) = (d, cost, sad); + moved = true; + } + } + if !moved { + break; + } + } + } + // half-sample refinement around the integer result + let centre = [2 * best_d[0], 2 * best_d[1]]; + let mut best = Found { mv: centre, sad: best_sad }; + for dy in -1..=1 { + for dx in -1..=1 { + let mv = [centre[0] + dx, centre[1] + dy]; + if (dx == 0 && dy == 0) || !self.valid(mv) { + continue; + } + let sad = self.sad_half(mv); + let cost = sad.saturating_add(self.mv_cost(mv)); + if cost < best_cost { + best_cost = cost; + best = Found { mv, sad }; + } + } + } + best + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured reference and a source macroblock cut from it at a known offset. + fn scene(dx: i32, dy: i32) -> (Frame, [u8; 256]) { + let mut f = Frame::new(64, 64, 32, 32); + for y in 0..64 { + for x in 0..64 { + let (x, y) = (x as f32, y as f32); + f.planes[0][y as usize * 64 + x as usize] = + (128.0 + 60.0 * (x * 0.31).sin() * (y * 0.23).cos() + 30.0 * ((x + y) * 0.13).sin()) as u8; + } + } + let mut src = [0u8; 256]; + for r in 0..16 { + for c in 0..16 { + src[r * 16 + c] = f.planes[0][(16 + dy + r as i32) as usize * 64 + (16 + dx + c as i32) as usize]; + } + } + (f, src) + } + + #[test] + fn finds_an_integer_displacement() { + let (f, src) = scene(3, -2); + let s = Search { reference: &f, src: &src, mbx: 1, mby: 1, f_code: 3, range: 15, pmv: [0, 0], lambda: 1 }; + let found = s.run(&[]); + assert_eq!(found, Found { mv: [6, -4], sad: 0 }); + } + + #[test] + fn stays_inside_the_reference() { + let (f, src) = scene(0, 0); + for (mbx, mby) in [(0, 0), (3, 3), (0, 3)] { + let s = Search { reference: &f, src: &src, mbx, mby, f_code: 4, range: 63, pmv: [0, 0], lambda: 0 }; + let found = s.run(&[[-200, -200], [200, 200]]); + assert!(s.valid(found.mv), "{found:?} at {mbx},{mby}"); + } + } +} diff --git a/crates/video/src/mpeg2/encode/picture.rs b/crates/video/src/mpeg2/encode/picture.rs new file mode 100644 index 000000000..e7bb84b7e --- /dev/null +++ b/crates/video/src/mpeg2/encode/picture.rs @@ -0,0 +1,412 @@ +//! Slices and macroblocks of one frame picture (H.262 §6.2.4-§6.2.6), with a reconstruction +//! that matches the decoder exactly. One slice per macroblock row; slices only depend on the +//! reference picture, so rows can be coded in parallel. + +use crate::mpeg2::headers::SCAN; +use crate::mpeg2::mc::{Block, Frame, predict}; +use crate::mpeg2::slice::NON_LINEAR_Q; +use crate::mpeg2::vlc::{MB_FWD, MB_INTRA, MB_PATTERN, MB_QUANT}; + +use super::bitwriter::BitWriter; +use super::motion::Search; +use super::tables::{EncTables, enc_tables, wrap_delta}; +use super::transform::{fdct, quant_inter, quant_intra, reconstruct}; + +/// Intra coding is chosen when its cost (the luma's deviation from its block means) undercuts +/// the best prediction's SAD by this much. +const INTRA_BIAS: u32 = 256; + +/// quantiser_scale for quantiser_scale_code `code` (Table 7-6 when `nonlinear`). +pub(crate) fn qscale(nonlinear: bool, code: u8) -> i32 { + if nonlinear { i32::from(NON_LINEAR_Q[usize::from(code & 31)]) } else { 2 * i32::from(code & 31) } +} + +/// The quantiser_scale_code (1..=31) whose quantiser_scale is closest to `qs`. +pub(crate) fn qcode_for(nonlinear: bool, qs: f64) -> u8 { + let mut best = (1u8, f64::MAX); + for code in 1..=31u8 { + let d = (f64::from(qscale(nonlinear, code)) - qs).abs(); + if d < best.1 { + best = (code, d); + } + } + best.0 +} + +/// What every slice of one picture shares (read-only). +pub(crate) struct PicCtx<'a> { + /// The padded source picture. + pub src: &'a Frame, + /// The reconstructed previous picture (P pictures); `None` codes an I picture. + pub reference: Option<&'a Frame>, + /// Vectors of the previous picture (motion search candidates), one per macroblock. + pub prev_mvs: &'a [[i32; 2]], + pub mb_w: usize, + pub shx: u32, + pub shy: u32, + pub intra_matrix: &'a [u8; 64], + pub inter_matrix: &'a [u8; 64], + pub nonlinear: bool, + /// intra_dc_precision (0..=2: 8..=10 bits). + pub dc_prec: u8, + pub f_code: u8, + pub search: i32, + /// quantiser_scale_code per macroblock. + pub mb_qcode: &'a [u8], +} + +impl PicCtx<'_> { + fn cmb(&self) -> (usize, usize) { + (16 >> self.shx, 16 >> self.shy) + } + fn blocks(&self) -> usize { + if self.shy == 1 { 6 } else { 8 } + } + fn dc_reset(&self) -> i32 { + 1 << (7 + self.dc_prec) + } +} + +/// The part of the reconstructed picture one macroblock row covers. +pub(crate) struct RowPlanes<'b> { + pub y: &'b mut [u8], + pub cb: &'b mut [u8], + pub cr: &'b mut [u8], +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct RowStats { + pub intra: u32, + pub skipped: u32, + /// Sum of the quantiser_scale of every macroblock. + pub qsum: u64, +} + +pub(crate) struct RowOut { + /// The slice, from its start code, byte aligned. + pub bytes: Vec, + /// The vector each macroblock used (zero for intra and skipped ones). + pub mvs: Vec<[i32; 2]>, + pub stats: RowStats, +} + +/// One macroblock's samples: luma 16×16, chroma rows of `16 >> shx`. +#[derive(Clone)] +struct MbPix { + y: [u8; 256], + c: [[u8; 256]; 2], +} + +impl MbPix { + fn load(f: &Frame, mbx: usize, mby: usize, (cbw, cbh): (usize, usize)) -> MbPix { + let mut m = MbPix { y: [0; 256], c: [[0; 256]; 2] }; + for r in 0..16 { + let at = (mby * 16 + r) * f.w + mbx * 16; + m.y[r * 16..r * 16 + 16].copy_from_slice(&f.planes[0][at..at + 16]); + } + for c in 0..2 { + for r in 0..cbh { + let at = (mby * cbh + r) * f.cw + mbx * cbw; + m.c[c][r * cbw..r * cbw + cbw].copy_from_slice(&f.planes[1 + c][at..at + cbw]); + } + } + m + } + + /// Plane, offset and stride of block `i` (H.262 Figure 6-10 order: four luma blocks, then + /// Cb, Cr, and for 4:2:2 the lower Cb, Cr). + fn block_at(i: usize, cbw: usize) -> (usize, usize, usize) { + if i < 4 { (0, (i >> 1) * 8 * 16 + (i & 1) * 8, 16) } else { (1 + (i & 1), ((i - 4) >> 1) * 8 * cbw, cbw) } + } + + fn plane(&self, p: usize) -> &[u8; 256] { + if p == 0 { &self.y } else { &self.c[p - 1] } + } + + fn plane_mut(&mut self, p: usize) -> &mut [u8; 256] { + if p == 0 { &mut self.y } else { &mut self.c[p - 1] } + } + + fn block(&self, i: usize, cbw: usize) -> [i32; 64] { + let (p, off, stride) = Self::block_at(i, cbw); + let d = self.plane(p); + std::array::from_fn(|k| i32::from(d[off + (k >> 3) * stride + (k & 7)])) + } + + /// Write `v` (intra samples) or add it to what is there (inter residual), saturated. + fn store(&mut self, i: usize, cbw: usize, v: &[i32; 64], add: bool) { + let (p, off, stride) = Self::block_at(i, cbw); + let d = self.plane_mut(p); + for (k, &x) in v.iter().enumerate() { + let at = off + (k >> 3) * stride + (k & 7); + let base = if add { i32::from(d[at]) } else { 0 }; + d[at] = (base + x).clamp(0, 255) as u8; + } + } + + fn put(&self, out: &mut RowPlanes, mbx: usize, (cbw, cbh): (usize, usize), (w, cw): (usize, usize)) { + for r in 0..16 { + let at = r * w + mbx * 16; + if let Some(d) = out.y.get_mut(at..at + 16) { + d.copy_from_slice(&self.y[r * 16..r * 16 + 16]); + } + } + for (c, plane) in [&mut *out.cb, &mut *out.cr].into_iter().enumerate() { + for r in 0..cbh { + let at = r * cw + mbx * cbw; + if let Some(d) = plane.get_mut(at..at + cbw) { + d.copy_from_slice(&self.c[c][r * cbw..r * cbw + cbw]); + } + } + } + } +} + +/// Slice coding state (what the decoder tracks too). +struct SliceSt { + qcode: u8, + dc: [i32; 3], + pmv: [i32; 2], + /// Skipped macroblocks not yet covered by an address increment. + skipped: usize, + /// The vector of the macroblock to the left (motion search candidate). + left_mv: [i32; 2], +} + +/// One macroblock being coded. +struct MbIn<'x> { + src: &'x MbPix, + mbx: usize, + mby: usize, + qcode: u8, +} + +/// Code macroblock row `row` as one slice, writing its reconstruction into `out`. +pub(crate) fn encode_row(ctx: &PicCtx, row: usize, out: &mut RowPlanes) -> RowOut { + let mut w = BitWriter::new(); + w.start_code(1 + row as u8); + let q0 = ctx.mb_qcode.get(row * ctx.mb_w).copied().unwrap_or(1); + w.put(u32::from(q0), 5); + w.bit(false); // extra_bit_slice + let mut st = SliceSt { qcode: q0, dc: [ctx.dc_reset(); 3], pmv: [0; 2], skipped: 0, left_mv: [0; 2] }; + let mut ro = RowOut { bytes: Vec::new(), mvs: vec![[0; 2]; ctx.mb_w], stats: RowStats::default() }; + for mbx in 0..ctx.mb_w { + macroblock(ctx, &mut st, &mut w, (mbx, row), out, &mut ro); + } + ro.bytes = w.finish(); + ro +} + +fn macroblock(ctx: &PicCtx, st: &mut SliceSt, w: &mut BitWriter, (mbx, mby): (usize, usize), out: &mut RowPlanes, ro: &mut RowOut) { + let addr = mby * ctx.mb_w + mbx; + let qcode = ctx.mb_qcode.get(addr).copied().unwrap_or(st.qcode); + let src = MbPix::load(ctx.src, mbx, mby, ctx.cmb()); + let mb = MbIn { src: &src, mbx, mby, qcode }; + let qs = qscale(ctx.nonlinear, qcode); + ro.stats.qsum += qs as u64; + let planes = (ctx.src.w, ctx.src.cw); + let Some(reference) = ctx.reference else { + intra_mb(ctx, st, w, &mb).put(out, mbx, ctx.cmb(), planes); + ro.stats.intra += 1; + return; + }; + let mut candidates = vec![st.left_mv, st.pmv]; + for a in [Some(addr), addr.checked_sub(ctx.mb_w), Some(addr + 1), Some(addr + ctx.mb_w)].into_iter().flatten() { + if let Some(&v) = ctx.prev_mvs.get(a) { + candidates.push(v); + } + } + let lambda = ((qs * 3 + 4) / 8).max(1) as u32; + let search = Search { reference, src: &src.y, mbx, mby, f_code: ctx.f_code, range: ctx.search, pmv: st.pmv, lambda }; + let found = search.run(&candidates); + if intra_cost(&src.y).saturating_add(INTRA_BIAS) < found.sad { + intra_mb(ctx, st, w, &mb).put(out, mbx, ctx.cmb(), planes); + ro.stats.intra += 1; + st.left_mv = [0; 2]; + return; + } + let (rec, skipped) = inter_mb(ctx, st, w, &mb, reference, found.mv); + rec.put(out, mbx, ctx.cmb(), planes); + ro.stats.skipped += skipped as u32; + ro.mvs[mbx] = found.mv; + st.left_mv = found.mv; +} + +/// Σ |sample − block mean| over the four luma blocks: the cost of coding them intra. +fn intra_cost(y: &[u8; 256]) -> u32 { + let mut cost = 0u32; + for b in 0..4 { + let off = (b >> 1) * 128 + (b & 1) * 8; + let at = |k: usize| u32::from(y[off + (k >> 3) * 16 + (k & 7)]); + let mean = ((0..64).map(at).sum::() + 32) / 64; + cost += (0..64).map(|k| at(k).abs_diff(mean)).sum::(); + } + cost +} + +fn put_increment(w: &mut BitWriter, t: &EncTables, mut n: usize) { + while n > 33 { + t.mba_escape.put(w); + n -= 33; + } + t.mba[n.max(1)].put(w); +} + +/// Write the coefficients of a block (raster-order levels) in zigzag order from `start`, then +/// end of block. +fn put_coefficients(w: &mut BitWriter, t: &EncTables, levels: &[i32; 64], table: usize, start: usize) { + let mut run = 0; + let mut first = start == 0; + for &pos in &SCAN[0][start..] { + let l = levels[usize::from(pos)]; + if l == 0 { + run += 1; + continue; + } + t.put_coefficient(w, table, run, l, first); + first = false; + run = 0; + } + t.dct_eob[table].put(w); +} + +fn put_mb_type(w: &mut BitWriter, t: &EncTables, predicted: bool, flags: i16) { + let table = if predicted { &t.mbtype_p } else { &t.mbtype_i }; + if let Some(c) = table.get(flags as usize).copied().flatten() { + c.put(w); + } +} + +/// Code an intra macroblock; returns its reconstruction. +fn intra_mb(ctx: &PicCtx, st: &mut SliceSt, w: &mut BitWriter, mb: &MbIn) -> MbPix { + let t = enc_tables(); + let quant = mb.qcode != st.qcode; + put_increment(w, t, st.skipped + 1); + st.skipped = 0; + put_mb_type(w, t, ctx.reference.is_some(), if quant { MB_INTRA | MB_QUANT } else { MB_INTRA }); + if quant { + w.put(u32::from(mb.qcode), 5); + st.qcode = mb.qcode; + } + st.pmv = [0; 2]; + let qs = qscale(ctx.nonlinear, st.qcode); + let dc_mult = 8 >> ctx.dc_prec; + let dc_max = (1 << (8 + ctx.dc_prec)) - 1; + let (cbw, _) = ctx.cmb(); + let mut rec = mb.src.clone(); + for i in 0..ctx.blocks() { + let cc = if i < 4 { 0 } else { 1 + (i & 1) }; + let f = fdct(&mb.src.block(i, cbw)); + let mut levels = [0i32; 64]; + levels[0] = ((f[0] / dc_mult as f32).round() as i32).clamp(0, dc_max); + for pos in 1..64 { + levels[pos] = quant_intra(f[pos], ctx.intra_matrix[pos], qs); + } + let diff = levels[0] - st.dc[cc]; + st.dc[cc] = levels[0]; + let size = 32 - diff.unsigned_abs().leading_zeros(); + let size_codes = if cc == 0 { &t.dc_luma } else { &t.dc_chroma }; + size_codes[size as usize].put(w); + if size > 0 { + let v = if diff > 0 { diff } else { diff + (1 << size) - 1 }; + w.put(v as u32, size); + } + put_coefficients(w, t, &levels, 1, 1); + rec.store(i, cbw, &reconstruct(&levels, ctx.intra_matrix, qs, true, dc_mult), false); + } + rec +} + +/// Code a predicted macroblock with vector `mv` (or skip it); returns its reconstruction and +/// whether it was skipped. +fn inter_mb(ctx: &PicCtx, st: &mut SliceSt, w: &mut BitWriter, mb: &MbIn, reference: &Frame, mv: [i32; 2]) -> (MbPix, bool) { + let t = enc_tables(); + let mut rec = predict_mb(ctx, reference, mv, mb.mbx, mb.mby); + let qs = qscale(ctx.nonlinear, mb.qcode); + let (cbw, _) = ctx.cmb(); + let n = ctx.blocks(); + let mut levels = [[0i32; 64]; 8]; + let mut cbp = 0u32; + for i in 0..n { + let (s, p) = (mb.src.block(i, cbw), rec.block(i, cbw)); + let f = fdct(&std::array::from_fn(|k| s[k] - p[k])); + for pos in 0..64 { + levels[i][pos] = quant_inter(f[pos], ctx.inter_matrix[pos], qs); + } + if levels[i].iter().any(|&l| l != 0) { + cbp |= 1 << (n - 1 - i); + } + } + st.dc = [ctx.dc_reset(); 3]; + // the first and last macroblock of a slice are always coded + let edge = mb.mbx == 0 || mb.mbx + 1 == ctx.mb_w; + if mv == [0, 0] && cbp == 0 && !edge { + st.skipped += 1; + st.pmv = [0; 2]; + return (rec, true); + } + put_increment(w, t, st.skipped + 1); + st.skipped = 0; + let quant = cbp != 0 && mb.qcode != st.qcode; + let mut flags = if cbp == 0 { + MB_FWD + } else if mv == [0, 0] { + MB_PATTERN + } else { + MB_FWD | MB_PATTERN + }; + if quant { + flags |= MB_QUANT; + } + put_mb_type(w, t, true, flags); + if quant { + w.put(u32::from(mb.qcode), 5); + st.qcode = mb.qcode; + } + if flags & MB_FWD != 0 { + for c in 0..2 { + t.put_motion(w, wrap_delta(mv[c], st.pmv[c], ctx.f_code), ctx.f_code); + } + st.pmv = mv; + } else { + st.pmv = [0; 2]; // "No MC" resets the predictors + } + if cbp != 0 { + if n == 8 { + t.cbp[(cbp >> 2) as usize].put(w); + w.put(cbp & 3, 2); + } else { + t.cbp[cbp as usize].put(w); + } + for i in 0..n { + if cbp & (1 << (n - 1 - i)) != 0 { + put_coefficients(w, t, &levels[i], 0, 0); + rec.store(i, cbw, &reconstruct(&levels[i], ctx.inter_matrix, qs, false, 0), true); + } + } + } + (rec, false) +} + +/// The frame prediction of a macroblock from `f` with vector `mv`, formed as the decoder forms +/// it (§7.6.4: chroma vectors are the luma ones halved towards zero). +fn predict_mb(ctx: &PicCtx, f: &Frame, mv: [i32; 2], mbx: usize, mby: usize) -> MbPix { + let (cbw, cbh) = ctx.cmb(); + let mut p = MbPix { y: [0; 256], c: [[0; 256]; 2] }; + let blk = + Block { x: (mbx * 16) as i32 + (mv[0] >> 1), y: (mby * 16) as i32 + (mv[1] >> 1), hx: mv[0] & 1 != 0, hy: mv[1] & 1 != 0, bw: 16, bh: 16 }; + predict(&mut p.y, 0, 16, f.plane(0, None), blk, false); + let cmv = [if ctx.shx == 1 { mv[0] / 2 } else { mv[0] }, if ctx.shy == 1 { mv[1] / 2 } else { mv[1] }]; + let cb = Block { + x: (mbx * cbw) as i32 + (cmv[0] >> 1), + y: (mby * cbh) as i32 + (cmv[1] >> 1), + hx: cmv[0] & 1 != 0, + hy: cmv[1] & 1 != 0, + bw: cbw, + bh: cbh, + }; + for c in 0..2 { + predict(&mut p.c[c], 0, cbw, f.plane(1 + c, None), cb, false); + } + p +} diff --git a/crates/video/src/mpeg2/encode/rate.rs b/crates/video/src/mpeg2/encode/rate.rs new file mode 100644 index 000000000..65c23efc3 --- /dev/null +++ b/crates/video/src/mpeg2/encode/rate.rs @@ -0,0 +1,102 @@ +//! Single-pass average bitrate control at picture level, after the target bit allocation of the +//! MPEG-2 Test Model 5: each picture type keeps a complexity `X = bits · average quantiser`, the +//! bits left for the GOP are shared out by complexity, and a picture's quantiser is `X / target`. +//! There is no VBV model: the average converges on the target, single pictures may not. + +pub(crate) struct RateCtl { + bps: f64, + fps: f64, + /// Complexity of I and P pictures. + xi: f64, + xp: f64, + /// Bits left for the current GOP (carries over between GOPs). + remaining: f64, + /// P pictures left in the current GOP. + np: u32, + /// Range of quantiser_scale values. + q_min: f64, + q_max: f64, +} + +impl RateCtl { + pub fn new(bps: f64, fps: f64, q_range: (f64, f64)) -> Self { + // TM5's starting complexities + Self { bps, fps, xi: 160.0 * bps / 115.0, xp: 60.0 * bps / 115.0, remaining: 0.0, np: 0, q_min: q_range.0, q_max: q_range.1 } + } + + /// A GOP of `pictures` pictures (one I, the rest P) starts. + pub fn start_gop(&mut self, pictures: u32) { + self.remaining += self.bps * f64::from(pictures) / self.fps; + self.np = pictures.saturating_sub(1); + } + + /// quantiser_scale for the next picture. + pub fn qscale(&self, intra: bool) -> f64 { + let floor = self.bps / (8.0 * self.fps); + let (target, x) = if intra { + (self.remaining / (1.0 + f64::from(self.np) * self.xp / self.xi), self.xi) + } else { + (self.remaining / f64::from(self.np.max(1)), self.xp) + }; + (x / target.max(floor)).clamp(self.q_min, self.q_max) + } + + /// The picture used `bits` at average quantiser_scale `avg_q`. + pub fn update(&mut self, intra: bool, bits: u64, avg_q: f64) { + let x = bits as f64 * avg_q; + if intra { + self.xi = x.max(1.0); + } else { + self.xp = x.max(1.0); + self.np = self.np.saturating_sub(1); + } + self.remaining -= bits as f64; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A picture whose size is `complexity / q` (the model the controller assumes). + fn run(complexity_i: f64, complexity_p: f64, bps: f64, gops: u32) -> f64 { + let fps = 25.0; + let mut rc = RateCtl::new(bps, fps, (1.0, 112.0)); + let mut total = 0.0; + for _ in 0..gops { + rc.start_gop(12); + for k in 0..12 { + let intra = k == 0; + let q = rc.qscale(intra); + let bits = (if intra { complexity_i } else { complexity_p }) / q; + rc.update(intra, bits as u64, q); + total += bits; + } + } + total * fps / (12.0 * f64::from(gops)) + } + + #[test] + fn converges_on_the_target() { + // complexities whose target quantiser is inside the allowed range + for (ci, cp) in [(4e7, 1e7), (8e7, 2e6), (1e7, 9e6)] { + let got = run(ci, cp, 4e6, 20); + assert!((got / 4e6 - 1.0).abs() < 0.03, "{ci} {cp}: {got}"); + } + } + + #[test] + fn the_quantiser_stays_in_range() { + let mut rc = RateCtl::new(1e6, 25.0, (2.0, 62.0)); + rc.start_gop(2); + // a picture far larger than its budget pushes the next quantiser to the top + rc.update(true, 50_000_000, 10.0); + rc.start_gop(2); + assert_eq!(rc.qscale(true), 62.0); + // and a tiny one to the bottom + let mut rc = RateCtl::new(1e6, 25.0, (2.0, 62.0)); + rc.start_gop(2); + rc.update(true, 10, 2.0); + assert_eq!(rc.qscale(true), 2.0); + } +} diff --git a/crates/video/src/mpeg2/encode/tables.rs b/crates/video/src/mpeg2/encode/tables.rs new file mode 100644 index 000000000..b79dd92ea --- /dev/null +++ b/crates/video/src/mpeg2/encode/tables.rs @@ -0,0 +1,264 @@ +//! Encoder-side variable-length codes, built from the decoder's code lists (H.262 Annex B) so the +//! two cannot disagree. + +use std::sync::OnceLock; + +use crate::mpeg2::vlc::{ + CBP, DC_CHROMA, DC_LUMA, DCT_EOB, DCT_ESCAPE, DCT_ONE, DCT_ZERO, DCT_ZERO_LONG, MBA, MBA_ESCAPE, MBTYPE_I, MBTYPE_P, MOTION, dct_table, + parse_code, +}; + +use super::bitwriter::BitWriter; + +/// A code: `len` bits of `bits`, most significant first. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub(crate) struct Code { + pub bits: u32, + pub len: u8, +} + +impl Code { + fn of(s: &str) -> Code { + let (bits, len) = parse_code(s); + Code { bits, len: len as u8 } + } + + #[inline(always)] + pub fn put(self, w: &mut BitWriter) { + w.put(self.bits, u32::from(self.len)); + } +} + +/// Longest run with a table code (table zero and one: run 31 at level 1). +const MAX_RUN: usize = 31; +/// Largest level with a table code (run 0: level 40). +const MAX_LEVEL: usize = 40; + +pub(crate) struct EncTables { + /// macroblock_address_increment 1..=33 (index 0 unused) and the escape. + pub mba: [Code; 34], + pub mba_escape: Code, + /// macroblock_type by flags: I pictures, P pictures (index: flags, 0..32). + pub mbtype_i: [Option; 32], + pub mbtype_p: [Option; 32], + /// coded_block_pattern_420 (the first six blocks). + pub cbp: [Code; 64], + /// motion_code -16..=16 at index code + 16. + pub motion: [Code; 33], + pub dc_luma: [Code; 12], + pub dc_chroma: [Code; 12], + /// DCT coefficients `[table][run][level]` without the sign bit (table 0: B.14, table 1: B.15). + pub dct: [Box<[[Option; MAX_LEVEL + 1]; MAX_RUN + 1]>; 2], + pub dct_eob: [Code; 2], + pub dct_escape: Code, +} + +fn by_value(list: &[(&str, i16)], offset: i16) -> [Code; N] { + let mut out = [Code::default(); N]; + for &(s, v) in list { + if let Some(slot) = usize::try_from(v + offset).ok().and_then(|i| out.get_mut(i)) { + *slot = Code::of(s); + } + } + out +} + +fn mb_types(list: &[(&str, i16)]) -> [Option; 32] { + let mut out = [None; 32]; + for &(s, v) in list { + if let Some(slot) = usize::try_from(v).ok().and_then(|i| out.get_mut(i)) { + *slot = Some(Code::of(s)); + } + } + out +} + +fn dct( + short: &'static [(&'static str, i16)], + extra: &'static [(&'static str, i16)], +) -> (Box<[[Option; MAX_LEVEL + 1]; MAX_RUN + 1]>, Code, Code) { + let mut t = Box::new([[None; MAX_LEVEL + 1]; MAX_RUN + 1]); + let (mut eob, mut esc) = (Code::default(), Code::default()); + for (s, v) in dct_table(short, extra) { + match v { + DCT_EOB => eob = Code::of(s), + DCT_ESCAPE => esc = Code::of(s), + v => { + let (run, level) = ((v & 63) as usize, (v >> 6) as usize); + if let Some(slot) = t.get_mut(run).and_then(|r| r.get_mut(level)) { + *slot = Some(Code::of(s)); + } + } + } + } + (t, eob, esc) +} + +pub(crate) fn enc_tables() -> &'static EncTables { + static T: OnceLock = OnceLock::new(); + T.get_or_init(|| { + let (zero, eob0, esc) = dct(DCT_ZERO, DCT_ZERO_LONG); + let (one, eob1, _) = dct(DCT_ONE, &[]); + let mba_escape = MBA.iter().find(|e| e.1 == MBA_ESCAPE).map_or(Code::default(), |e| Code::of(e.0)); + EncTables { + mba: by_value(MBA, 0), + mba_escape, + mbtype_i: mb_types(MBTYPE_I), + mbtype_p: mb_types(MBTYPE_P), + cbp: by_value(CBP, 0), + motion: by_value(MOTION, 16), + dc_luma: by_value(DC_LUMA, 0), + dc_chroma: by_value(DC_CHROMA, 0), + dct: [zero, one], + dct_eob: [eob0, eob1], + dct_escape: esc, + } + }) +} + +impl EncTables { + /// One DCT coefficient. `first_inter`: the first coefficient of a non-intra block, where + /// run 0 level ±1 is "1s". + #[inline] + pub fn put_coefficient(&self, w: &mut BitWriter, table: usize, run: usize, level: i32, first_inter: bool) { + let mag = level.unsigned_abs() as usize; + let neg = level < 0; + if first_inter && run == 0 && mag == 1 { + w.put(0b10 | neg as u32, 2); + return; + } + if let Some(c) = self.dct[table & 1].get(run).and_then(|r| r.get(mag)).copied().flatten() { + c.put(w); + w.bit(neg); + return; + } + // escape: 6-bit run, 12-bit two's complement level (-2047..=2047) + self.dct_escape.put(w); + w.put(run as u32, 6); + w.put((level.clamp(-2047, 2047) & 0xFFF) as u32, 12); + } + + /// Bits of one motion vector component delta (`delta` already in the wrapped range). + #[inline] + pub fn motion_bits(&self, delta: i32, f_code: u8) -> u32 { + let r_size = u32::from(f_code.saturating_sub(1)); + if delta == 0 { + return u32::from(self.motion[16].len); + } + let code = ((delta.abs() - 1) >> r_size) + 1; + let idx = (16 + code.min(16)) as usize; + u32::from(self.motion[idx].len) + r_size + } + + /// Write one motion vector component delta (`delta` in the wrapped range). + #[inline] + pub fn put_motion(&self, w: &mut BitWriter, delta: i32, f_code: u8) { + let r_size = u32::from(f_code.saturating_sub(1)); + if delta == 0 { + self.motion[16].put(w); + return; + } + let a = delta.abs() - 1; + let code = ((a >> r_size) + 1).min(16); + let idx = (16 + code * delta.signum()) as usize; + self.motion[idx].put(w); + w.put((a & ((1 << r_size) - 1)) as u32, r_size); + } +} + +/// `mv - pred` brought into the range a motion code can carry ([-16f, 16f-1], §7.6.3.1). +#[inline] +pub(crate) fn wrap_delta(mv: i32, pred: i32, f_code: u8) -> i32 { + let f = 1i32 << f_code.saturating_sub(1); + let d = mv - pred; + if d > 16 * f - 1 { + d - 32 * f + } else if d < -16 * f { + d + 32 * f + } else { + d + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mpeg2::bits::Bits; + use crate::mpeg2::slice::wrap_vector; + use crate::mpeg2::vlc::{MB_FWD, MB_INTRA, MB_PATTERN, MB_QUANT, rl, tables}; + + /// Macroblock types the encoder sends (frame pictures, frame prediction, no B pictures). + const ENC_MB_TYPES: [i16; 7] = + [MB_INTRA, MB_INTRA | MB_QUANT, MB_FWD | MB_PATTERN, MB_PATTERN, MB_FWD, MB_QUANT | MB_FWD | MB_PATTERN, MB_QUANT | MB_PATTERN]; + + #[test] + fn every_code_decodes_to_its_value() { + let t = enc_tables(); + let d = tables(); + let check = |c: Code, vlc: &crate::mpeg2::vlc::Vlc, v: i16| { + let mut w = BitWriter::new(); + c.put(&mut w); + let bytes = w.finish(); + let mut b = Bits::new(&bytes); + assert_eq!(vlc.decode(&mut b), Some(v)); + }; + for n in 1..=33 { + check(t.mba[n], &d.mba, n as i16); + } + check(t.mba_escape, &d.mba, MBA_ESCAPE); + for v in 0..64 { + check(t.cbp[v], &d.cbp, v as i16); + } + for v in -16..=16i16 { + check(t.motion[(v + 16) as usize], &d.motion, v); + } + for s in 0..12 { + check(t.dc_luma[s], &d.dc_luma, s as i16); + check(t.dc_chroma[s], &d.dc_chroma, s as i16); + } + for f in ENC_MB_TYPES { + // the I table holds only the intra types + if let Some(c) = t.mbtype_i[f as usize] { + check(c, &d.mbtype[0], f); + } + check(t.mbtype_p[f as usize].unwrap_or_default(), &d.mbtype[1], f); + } + for (k, vlc) in [&d.dct_zero, &d.dct_one].into_iter().enumerate() { + for run in 0..=MAX_RUN { + for level in 1..=MAX_LEVEL { + if let Some(c) = t.dct[k][run][level] { + check(c, vlc, rl(run as i16, level as i16)); + } + } + } + check(t.dct_eob[k], vlc, DCT_EOB); + } + } + + #[test] + fn motion_deltas_round_trip_through_the_decoder_wrap() { + let t = enc_tables(); + let d = tables(); + for f_code in 1..=4u8 { + let f = 1i32 << (f_code - 1); + for pred in [-16 * f, -3, 0, 5, 16 * f - 1] { + for mv in -16 * f..16 * f { + let delta = wrap_delta(mv, pred, f_code); + let mut w = BitWriter::new(); + t.put_motion(&mut w, delta, f_code); + let bits = w.bits() as u32; + assert_eq!(bits, t.motion_bits(delta, f_code)); + let bytes = w.finish(); + let mut b = Bits::new(&bytes); + // the decoder's reading of motion_code and motion_residual + let code = d.motion.decode(&mut b).unwrap_or(99) as i32; + let r_size = (f_code - 1) as u32; + let residual = if f_code != 1 && code != 0 { b.read(r_size) as i32 } else { 0 }; + let dd = if f == 1 || code == 0 { code } else { ((code.abs() - 1) * f + residual + 1) * code.signum() }; + assert_eq!(dd, delta); + assert_eq!(wrap_vector(pred, dd, f), mv, "f_code {f_code} pred {pred} mv {mv}"); + } + } + } + } +} diff --git a/crates/video/src/mpeg2/encode/tests.rs b/crates/video/src/mpeg2/encode/tests.rs new file mode 100644 index 000000000..5352161b3 --- /dev/null +++ b/crates/video/src/mpeg2/encode/tests.rs @@ -0,0 +1,250 @@ +//! Encoder tests against this crate's decoder: the decoded pictures must equal the encoder's +//! reconstruction exactly (any difference would drift along a GOP of P pictures). The ffmpeg +//! comparison is in `tests/mpeg2/encode.rs`. + +use super::*; +use crate::mpeg2::{Decoder, access_units, probe}; + +/// A moving, textured test sequence: a gradient with a sharp pattern drifting by a fraction of a +/// sample per picture, and a block moving across it. +pub(crate) fn scene(w: usize, h: usize, cw: usize, ch: usize, t: usize) -> (Vec, Vec, Vec) { + let tf = t as f32; + let mut y = vec![0u8; w * h]; + for r in 0..h { + for c in 0..w { + let (x, yy) = (c as f32 + tf * 1.5, r as f32 + tf * 0.75); + let v = 60.0 + + 0.3 * x + + 0.2 * yy + + 40.0 * ((x * 0.21).sin() * (yy * 0.17).cos()) + + if ((x as i32 / 8) + (yy as i32 / 8)) % 2 == 0 { 25.0 } else { 0.0 }; + y[r * w + c] = v.clamp(0.0, 255.0) as u8; + } + } + let (bx, by) = ((t * 3) % w.max(1), (t * 2) % h.max(1)); + for r in by..(by + 12).min(h) { + for c in bx..(bx + 12).min(w) { + y[r * w + c] = 230; + } + } + let mut cb = vec![0u8; cw * ch]; + let mut cr = vec![0u8; cw * ch]; + for r in 0..ch { + for c in 0..cw { + cb[r * cw + c] = (128.0 + 50.0 * ((c as f32 + tf) * 0.13).sin()) as u8; + cr[r * cw + c] = (128.0 + 40.0 * ((r as f32 - tf) * 0.11).cos()) as u8; + } + } + (y, cb, cr) +} + +/// Encode `frames` pictures of the scene; returns the stream and the reconstruction. +fn encode(cfg: EncoderConfig, frames: usize) -> (Vec, Vec, Vec) { + let (w, h) = (cfg.width as usize, cfg.height as usize); + let (cw, ch) = (w / 2, if cfg.chroma == ChromaFormat::Yuv422 { h } else { h / 2 }); + let mut enc = Encoder::new(cfg).unwrap(); + enc.set_recon_capture(true); + let mut es = Vec::new(); + let mut packets = Vec::new(); + for t in 0..frames { + let (y, cb, cr) = scene(w, h, cw, ch, t); + let f = YuvFrame { y: &y, cb: &cb, cr: &cr, y_stride: w, c_stride: cw }; + for p in enc.encode(&f, t as i64).unwrap() { + es.extend_from_slice(&p.data); + packets.push(p); + } + } + packets.extend(enc.flush()); + es.extend_from_slice(enc.end_of_sequence()); + (es, enc.take_recon(), packets) +} + +fn decode(es: &[u8]) -> Vec { + let mut d = Decoder::new(); + let mut out = Vec::new(); + for (i, r) in access_units(es).into_iter().enumerate() { + out.extend(d.decode(&es[r], i as i64).unwrap()); + } + out.extend(d.flush()); + assert_eq!(d.errors(), 0, "decoder errors: {:?}", d.last_error()); + out +} + +fn assert_decodes_to_recon(name: &str, cfg: EncoderConfig, frames: usize) -> Vec { + let (es, recon, packets) = encode(cfg, frames); + let pics = decode(&es); + assert_eq!(pics.len(), frames, "{name}: picture count"); + for (i, (p, r)) in pics.iter().zip(&recon).enumerate() { + assert_eq!(p.pts, i as i64, "{name}: display order"); + for (plane, (a, b)) in [("Y", (&p.y, &r.y)), ("Cb", (&p.cb, &r.cb)), ("Cr", (&p.cr, &r.cr))] { + let first = a.iter().zip(b.iter()).position(|(x, y)| x != y); + assert!(first.is_none() && a.len() == b.len(), "{name}: picture {i} ({:?}) {plane} differs at {first:?}", p.picture_type); + } + } + packets +} + +fn psnr(a: &[u8], b: &[u8]) -> f64 { + let sse: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum(); + 10.0 * (255.0f64.powi(2) * a.len() as f64 / sse.max(1e-9)).log10() +} + +#[test] +fn intra_only_decodes_to_the_reconstruction() { + let mut cfg = EncoderConfig::new(64, 48, 25, 1); + cfg.gop_size = 1; + let packets = assert_decodes_to_recon("intra", cfg, 3); + assert!(packets.iter().all(|p| p.keyframe && p.picture_type == PictureType::I)); +} + +#[test] +fn long_p_gop_does_not_drift() { + let mut cfg = EncoderConfig::new(96, 64, 25, 1); + cfg.gop_size = 30; + let packets = assert_decodes_to_recon("long gop", cfg, 30); + assert_eq!(packets.iter().filter(|p| p.keyframe).count(), 1); + assert!(packets[1..].iter().all(|p| p.picture_type == PictureType::P)); +} + +#[test] +fn every_coding_option_decodes_to_the_reconstruction() { + let base = EncoderConfig { gop_size: 6, ..EncoderConfig::new(80, 48, 30000, 1001) }; + let mut matrix = [0u8; 64]; + for (i, m) in matrix.iter_mut().enumerate() { + *m = (8 + i * 2) as u8; + } + let cases = [ + ("422", EncoderConfig { chroma: ChromaFormat::Yuv422, ..base.clone() }), + ("linear q", EncoderConfig { nonlinear_quant: false, rate: RateControl::Quantiser(3), ..base.clone() }), + ("coarse", EncoderConfig { rate: RateControl::Quantiser(31), ..base.clone() }), + ("fine", EncoderConfig { rate: RateControl::Quantiser(1), adaptive_quant: false, ..base.clone() }), + ("dc 8 and 10", EncoderConfig { intra_dc_bits: 10, ..base.clone() }), + ("dc 8", EncoderConfig { intra_dc_bits: 8, ..base.clone() }), + ("matrices", EncoderConfig { intra_matrix: Some(matrix), non_intra_matrix: Some(matrix), ..base.clone() }), + ("small range", EncoderConfig { search_range: 3, ..base.clone() }), + ("abr", EncoderConfig { rate: RateControl::Abr { kbps: 300 }, ..base.clone() }), + ("odd size", EncoderConfig { width: 50, height: 34, ..base.clone() }), + ("422 odd size", EncoderConfig { width: 70, height: 30, chroma: ChromaFormat::Yuv422, ..base.clone() }), + ]; + for (name, cfg) in cases { + assert_decodes_to_recon(name, cfg, 8); + } +} + +#[cfg(feature = "threads")] +#[test] +fn threaded_slices_give_the_same_stream() { + let cfg = EncoderConfig { gop_size: 5, threads: false, ..EncoderConfig::new(96, 80, 25, 1) }; + let (a, _, _) = encode(cfg.clone(), 6); + let (b, _, _) = encode(EncoderConfig { threads: true, ..cfg }, 6); + assert_eq!(a, b); +} + +#[test] +fn quality_follows_the_quantiser() { + let mut last = f64::MAX; + let mut last_size = usize::MAX; + for q in [1u8, 4, 12, 31] { + let cfg = EncoderConfig { rate: RateControl::Quantiser(q), gop_size: 4, ..EncoderConfig::new(96, 64, 25, 1) }; + let (es, recon, _) = encode(cfg, 4); + let p: f64 = recon.iter().enumerate().map(|(t, r)| psnr(&scene(96, 64, 48, 32, t).0, &r.y)).sum::() / recon.len() as f64; + assert!(p < last && es.len() < last_size, "q {q}: {p:.2} dB, {} bytes", es.len()); + if q == 1 { + assert!(p > 45.0, "q 1: {p:.2} dB"); + } + if q == 4 { + assert!(p > 36.0, "q 4: {p:.2} dB"); + } + (last, last_size) = (p, es.len()); + } +} + +#[test] +fn p_pictures_are_smaller_and_static_content_skips() { + let cfg = EncoderConfig { gop_size: 8, ..EncoderConfig::new(128, 64, 25, 1) }; + let mut enc = Encoder::new(cfg).unwrap(); + let (y, cb, cr) = scene(128, 64, 64, 32, 0); + let f = YuvFrame { y: &y, cb: &cb, cr: &cr, y_stride: 128, c_stride: 64 }; + let i = enc.encode(&f, 0).unwrap().remove(0); + let p1 = enc.encode(&f, 1).unwrap().remove(0); + let p2 = enc.encode(&f, 2).unwrap().remove(0); + assert_eq!(p1.picture_type, PictureType::P); + // the first P picture refines the I picture's reconstruction; the next repeat has almost + // nothing left to code and skips (every macroblock but the first and last of a slice) + assert!(p1.data.len() * 5 < i.data.len(), "I {} bytes, P {} bytes", i.data.len(), p1.data.len()); + assert!(p2.data.len() * 20 < i.data.len(), "I {} bytes, second P {} bytes", i.data.len(), p2.data.len()); +} + +#[test] +fn the_sequence_header_describes_the_stream() { + let cases = [ + (EncoderConfig::new(352, 288, 25, 1), 0x4A, "Main@Low"), + (EncoderConfig::new(720, 576, 25, 1), 0x48, "Main@Main"), + (EncoderConfig::new(1440, 1080, 25, 1), 0x46, "Main@High-1440"), + (EncoderConfig::new(1920, 1080, 30000, 1001), 0x44, "Main@High"), + (EncoderConfig { chroma: ChromaFormat::Yuv422, ..EncoderConfig::new(720, 608, 25, 1) }, 0x85, "4:2:2@Main"), + (EncoderConfig { chroma: ChromaFormat::Yuv422, ..EncoderConfig::new(1920, 1080, 25, 1) }, 0x82, "4:2:2@High"), + (EncoderConfig { rate: RateControl::Abr { kbps: 20_000 }, ..EncoderConfig::new(720, 576, 25, 1) }, 0x46, "Main@High-1440"), + ]; + for (cfg, pli, name) in cases { + let (w, h, aspect) = (cfg.width, cfg.height, cfg.aspect); + let enc = Encoder::new(cfg).unwrap(); + assert_eq!(enc.profile_and_level(), pli, "{name}"); + let info = probe(enc.sequence_header()).unwrap(); + assert_eq!((info.width, info.height, info.mpeg2, info.progressive_sequence, info.low_delay), (w, h, true, true, true)); + assert_eq!(info.codec_name(), format!("MPEG-2 Video ({name})")); + assert_eq!(aspect, AspectRatio::Square); + assert_eq!(info.sar, (1, 1)); + } + let enc = Encoder::new(EncoderConfig { aspect: AspectRatio::Dar16x9, ..EncoderConfig::new(720, 576, 30000, 1001) }).unwrap(); + let info = probe(enc.sequence_header()).unwrap(); + assert_eq!((info.dar, info.frame_rate), (Some((16, 9)), Some((30000, 1001)))); +} + +#[test] +fn bad_configurations_are_refused() { + let ok = EncoderConfig::new(64, 64, 25, 1); + let bad = [ + EncoderConfig { width: 63, ..ok.clone() }, + EncoderConfig { height: 0, ..ok.clone() }, + EncoderConfig { width: 3840, height: 2160, ..ok.clone() }, + EncoderConfig { chroma: ChromaFormat::Yuv444, ..ok.clone() }, + EncoderConfig { chroma: ChromaFormat::Yuv422, width: 1920, height: 1152, ..ok.clone() }, + EncoderConfig { fps_num: 26, ..ok.clone() }, + EncoderConfig { fps_den: 0, ..ok.clone() }, + EncoderConfig { gop_size: 0, ..ok.clone() }, + EncoderConfig { gop_size: 1024, ..ok.clone() }, + EncoderConfig { rate: RateControl::Quantiser(0), ..ok.clone() }, + EncoderConfig { rate: RateControl::Quantiser(32), ..ok.clone() }, + EncoderConfig { rate: RateControl::Abr { kbps: 0 }, ..ok.clone() }, + EncoderConfig { rate: RateControl::Abr { kbps: 90_000 }, ..ok.clone() }, + EncoderConfig { intra_dc_bits: 11, ..ok.clone() }, + EncoderConfig { intra_matrix: Some([0; 64]), ..ok.clone() }, + EncoderConfig { search_range: 0, ..ok.clone() }, + EncoderConfig { search_range: 64, ..ok.clone() }, + ]; + for cfg in bad { + let desc = format!("{cfg:?}"); + assert!(matches!(Encoder::new(cfg), Err(EncodeError::Config(_))), "{desc}"); + } +} + +#[test] +fn short_or_inconsistent_planes_are_refused() { + let mut enc = Encoder::new(EncoderConfig::new(32, 32, 25, 1)).unwrap(); + let (y, c) = (vec![0u8; 32 * 32], vec![0u8; 16 * 16]); + let ok = YuvFrame { y: &y, cb: &c, cr: &c, y_stride: 32, c_stride: 16 }; + assert!(enc.encode(&ok, 0).is_ok()); + let bad = [ + YuvFrame { y: &y[..32 * 31], ..ok }, + YuvFrame { cb: &c[..10], ..ok }, + YuvFrame { y_stride: 31, ..ok }, + YuvFrame { c_stride: usize::MAX, ..ok }, + YuvFrame { y_stride: usize::MAX / 2, ..ok }, + ]; + for f in bad { + assert!(matches!(enc.encode(&f, 1), Err(EncodeError::BadFrame(_)))); + } + // a refused frame leaves the encoder usable + assert_eq!(enc.encode(&ok, 1).unwrap()[0].picture_type, PictureType::P); +} diff --git a/crates/video/src/mpeg2/encode/transform.rs b/crates/video/src/mpeg2/encode/transform.rs new file mode 100644 index 000000000..d3a6ce4d3 --- /dev/null +++ b/crates/video/src/mpeg2/encode/transform.rs @@ -0,0 +1,122 @@ +//! Forward DCT, quantisation and the decoder-exact reconstruction of one block. + +use crate::mpeg2::idct::{basis, idct}; +use crate::mpeg2::slice::dequant_mpeg2; + +/// Forward 8×8 DCT of `p` (raster order), the transpose of the decoder's IDCT basis: +/// `F[v][u] = Σ_y Σ_x M[x][u] M[y][v] p[y][x]`, so a flat block of value `a` has `F[0][0] = 8a`. +pub(crate) fn fdct(p: &[i32; 64]) -> [f32; 64] { + let m = basis(); + let mut t = [0f32; 64]; + for y in 0..8 { + let row: [f32; 8] = std::array::from_fn(|x| p[y * 8 + x] as f32); + for u in 0..8 { + let mut s = 0f32; + for x in 0..8 { + s += m[x][u] * row[x]; + } + t[y * 8 + u] = s; + } + } + let mut out = [0f32; 64]; + for v in 0..8 { + for u in 0..8 { + let mut s = 0f32; + for y in 0..8 { + s += m[y][v] * t[y * 8 + u]; + } + out[v * 8 + u] = s; + } + } + out +} + +/// Rounding offset of intra AC quantisation (in units of the quantiser step): below one half, +/// a small dead zone. +const INTRA_ROUND: f32 = 0.375; + +/// Quantise intra AC coefficient `f` with weight `w` and quantiser_scale `qs` (inverse of +/// `F = 2·QF·w·qs / 32`). +#[inline] +pub(crate) fn quant_intra(f: f32, w: u8, qs: i32) -> i32 { + let y = 16.0 * f.abs() / (f32::from(w) * qs as f32); + let q = ((y + INTRA_ROUND).floor() as i32).min(2047); + if f < 0.0 { -q } else { q } +} + +/// Quantise non-intra coefficient `f` (inverse of `F = (2·QF + sign)·w·qs / 32`): truncation, +/// which leaves the usual wide dead zone around zero. +#[inline] +pub(crate) fn quant_inter(f: f32, w: u8, qs: i32) -> i32 { + let y = 16.0 * f.abs() / (f32::from(w) * qs as f32); + let q = (y.floor() as i32).min(2047); + if f < 0.0 { -q } else { q } +} + +/// Reconstruct a block as the decoder does: dequantise `levels` (raster order; for intra blocks +/// `levels[0]` is the quantised DC), mismatch control, IDCT. Returns the residual (inter) or +/// the samples before clamping (intra). +pub(crate) fn reconstruct(levels: &[i32; 64], matrix: &[u8; 64], qs: i32, intra: bool, dc_mult: i32) -> [i32; 64] { + let mut blk = [0i32; 64]; + let mut sum = 0i32; + for (i, &l) in levels.iter().enumerate() { + if l == 0 { + continue; + } + let v = if intra && i == 0 { l * dc_mult } else { dequant_mpeg2(l, qs, i32::from(matrix[i]), intra) }; + blk[i] = v; + sum += v; + } + if sum & 1 == 0 { + blk[63] ^= 1; + } + let dc_only = blk[1..].iter().all(|&v| v == 0); + idct(&mut blk, dc_only); + blk +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flat_block_has_only_dc() { + let f = fdct(&[100; 64]); + assert!((f[0] - 800.0).abs() < 0.01); + assert!(f[1..].iter().all(|v| v.abs() < 0.01)); + } + + #[test] + fn forward_then_inverse_is_close() { + let p: [i32; 64] = std::array::from_fn(|i| ((i * 37 + i / 8 * 11) % 200) as i32 - 60); + let f = fdct(&p); + let mut b: [i32; 64] = std::array::from_fn(|i| f[i].round() as i32); + idct(&mut b, false); + for i in 0..64 { + assert!((b[i] - p[i]).abs() <= 1, "{i}: {} vs {}", b[i], p[i]); + } + } + + #[test] + fn quantisers_invert_the_dequantiser() { + for qs in [1, 2, 8, 31, 62, 112] { + for w in [8u8, 16, 40, 83] { + // only where the dequantiser's integer division is exact + if (i32::from(w) * qs) % 32 != 0 { + continue; + } + for level in [-40, -3, -1, 1, 2, 17] { + let f = dequant_mpeg2(level, qs, i32::from(w), true) as f32; + // without the clamp the intra quantiser returns the level it came from + if f.abs() < 2047.0 { + assert_eq!(quant_intra(f, w, qs), level, "intra qs {qs} w {w}"); + } + let f = dequant_mpeg2(level, qs, i32::from(w), false) as f32; + if f.abs() < 2047.0 { + assert_eq!(quant_inter(f, w, qs), level, "inter qs {qs} w {w}"); + } + } + } + } + } +} diff --git a/crates/video/src/mpeg2/idct.rs b/crates/video/src/mpeg2/idct.rs index f94b935f0..3828d05a6 100644 --- a/crates/video/src/mpeg2/idct.rs +++ b/crates/video/src/mpeg2/idct.rs @@ -6,7 +6,7 @@ use std::sync::OnceLock; /// `M[x][u] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`, `C(u>0) = 1`. -fn basis() -> &'static [[f32; 8]; 8] { +pub(crate) fn basis() -> &'static [[f32; 8]; 8] { static B: OnceLock<[[f32; 8]; 8]> = OnceLock::new(); B.get_or_init(|| { let mut m = [[0f32; 8]; 8]; diff --git a/crates/video/src/mpeg2/mod.rs b/crates/video/src/mpeg2/mod.rs index 5d88ea4e5..ae2bb1682 100644 --- a/crates/video/src/mpeg2/mod.rs +++ b/crates/video/src/mpeg2/mod.rs @@ -1,5 +1,6 @@ //! Clean-room MPEG-2 video (ITU-T H.262 | ISO/IEC 13818-2) and MPEG-1 video (ISO/IEC 11172-2) -//! decoder. +//! decoder, and the start of an MPEG-2 encoder ([`encode`], feature `mpeg2-encode`: I and P +//! pictures). //! //! - Main Profile at Low/Main/High-1440/High Level, 4:2:2 Profile (XDCAM HD422, IMX / D-10), //! Simple Profile, and MPEG-1 (including D pictures and constrained-parameters streams). @@ -26,12 +27,17 @@ #![allow(clippy::needless_range_loop, clippy::type_complexity)] mod bits; +#[cfg(feature = "mpeg2-encode")] +pub mod encode; pub mod headers; mod idct; mod mc; mod slice; mod vlc; +#[cfg(feature = "mpeg2-encode")] +pub use encode::{AspectRatio, EncodeError, Encoder, EncoderConfig, Packet, RateControl, ReconFrame, YuvFrame}; + use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/video/src/mpeg2/slice.rs b/crates/video/src/mpeg2/slice.rs index f7a6389f0..706439af4 100644 --- a/crates/video/src/mpeg2/slice.rs +++ b/crates/video/src/mpeg2/slice.rs @@ -519,8 +519,7 @@ pub(crate) fn wrap_vector(pred: i32, delta: i32, f: i32) -> i32 { #[inline(always)] fn dequant(p: &PicParams, level: i32, qs: i32, w: i32, intra: bool) -> i32 { if p.mpeg2 { - let k = if intra { 0 } else { level.signum() }; - ((2 * level + k) * w * qs / 32).clamp(-2048, 2047) + dequant_mpeg2(level, qs, w, intra) } else { let mut v = if intra { 2 * level * qs * w / 16 } else { (2 * level + level.signum()) * qs * w / 16 }; if v & 1 == 0 { @@ -530,6 +529,14 @@ fn dequant(p: &PicParams, level: i32, qs: i32, w: i32, intra: bool) -> i32 { } } +/// MPEG-2 inverse quantisation of one AC (or non-intra) coefficient (§7.4.2.3), saturated +/// (§7.4.3). Shared with the encoder, whose reconstruction must match the decoder exactly. +#[inline(always)] +pub(crate) fn dequant_mpeg2(level: i32, qs: i32, w: i32, intra: bool) -> i32 { + let k = if intra { 0 } else { level.signum() }; + ((2 * level + k) * w * qs / 32).clamp(-2048, 2047) +} + /// The reference field (or frame) for direction `s` and field `sel` (field prediction). fn reference<'a>(p: &PicParams<'a>, s: usize, sel: usize) -> Option<&'a Frame> { if s == 1 { diff --git a/crates/video/src/mpeg2/vlc.rs b/crates/video/src/mpeg2/vlc.rs index 192f86be0..3f8015bb5 100644 --- a/crates/video/src/mpeg2/vlc.rs +++ b/crates/video/src/mpeg2/vlc.rs @@ -22,7 +22,7 @@ pub(crate) struct Vlc { table: Vec, } -fn parse_code(s: &str) -> (u32, u32) { +pub(crate) fn parse_code(s: &str) -> (u32, u32) { let mut v = 0u32; let mut n = 0u32; for c in s.chars() { diff --git a/crates/video/tests/mpeg2/encode.rs b/crates/video/tests/mpeg2/encode.rs new file mode 100644 index 000000000..5cb2377dc --- /dev/null +++ b/crates/video/tests/mpeg2/encode.rs @@ -0,0 +1,206 @@ +//! The MPEG-2 encoder against ffmpeg as an external decoder: ffmpeg must decode our streams +//! without errors, to within the decoder suite's tolerance of the encoder's own reconstruction +//! (ffmpeg's integer IDCT differs from ours), and report the profile, level and picture types +//! we signal. Source pictures come from ffmpeg's `testsrc2`. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use craft_video::mpeg2::{ChromaFormat, Encoder, EncoderConfig, RateControl, ReconFrame, YuvFrame}; + +use crate::common::{Stats, fixture_dir, pix_fmt_of}; +use crate::oracle::{MAX_DIFFERING, MIN_PSNR, TOL_MAX}; + +/// Raw source frames from ffmpeg's `testsrc2`. +fn source(ff: &Path, w: u32, h: u32, frames: usize, pix_fmt: &str) -> Vec { + let o = Command::new(ff) + .args([ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + &format!("testsrc2=size={w}x{h}:rate=25"), + "-frames:v", + &frames.to_string(), + ]) + .args(["-pix_fmt", pix_fmt, "-f", "rawvideo", "-"]) + .stdin(Stdio::null()) + .output() + .expect("run ffmpeg"); + assert!(o.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&o.stderr)); + o.stdout +} + +fn sizes(cfg: &EncoderConfig) -> (usize, usize, usize) { + let (w, h) = (cfg.width as usize, cfg.height as usize); + let ch = if cfg.chroma == ChromaFormat::Yuv422 { h } else { h / 2 }; + (w * h, w / 2 * ch, w / 2) +} + +/// Encode `raw` frames; returns the elementary stream and the reconstruction. +fn encode(cfg: &EncoderConfig, raw: &[u8]) -> (Vec, Vec) { + let (ys, cs, cw) = sizes(cfg); + let fsize = ys + 2 * cs; + let mut enc = Encoder::new(cfg.clone()).expect("config"); + enc.set_recon_capture(true); + let mut es = Vec::new(); + for (i, f) in raw.chunks_exact(fsize).enumerate() { + let frame = YuvFrame { y: &f[..ys], cb: &f[ys..ys + cs], cr: &f[ys + cs..], y_stride: cfg.width as usize, c_stride: cw }; + for p in enc.encode(&frame, i as i64).expect("encode") { + es.extend_from_slice(&p.data); + } + } + for p in enc.flush() { + es.extend_from_slice(&p.data); + } + es.extend_from_slice(enc.end_of_sequence()); + (es, enc.take_recon()) +} + +/// ffmpeg's decode of `file`, failing on any decoding error. +fn ffmpeg_decode(ff: &Path, file: &Path, pix_fmt: &str) -> Vec { + let o = Command::new(ff) + .args(["-hide_banner", "-loglevel", "error", "-xerror", "-err_detect", "explode", "-i"]) + .arg(file) + .args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) + .stdin(Stdio::null()) + .output() + .expect("run ffmpeg"); + assert!(o.status.success() && o.stderr.is_empty(), "ffmpeg rejects {}: {}", file.display(), String::from_utf8_lossy(&o.stderr)); + o.stdout +} + +/// ffprobe's stream description and per-frame picture types. +fn ffprobe(file: &Path) -> Option<(String, Vec)> { + let fp = craft_media_testkit::ffprobe()?; + let stream = Command::new(&fp) + .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,profile,level,pix_fmt", "-of", "default=nw=1"]) + .arg(file) + .output() + .ok()?; + let frames = Command::new(&fp) + .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pict_type", "-of", "csv=p=0"]) + .arg(file) + .output() + .ok()?; + let types = String::from_utf8_lossy(&frames.stdout).lines().map(|l| l.trim_end_matches(',').to_string()).filter(|l| !l.is_empty()).collect(); + // "codec_name=…\nprofile=…" in ffprobe's order → "codec,profile,level,pix_fmt" + let text = String::from_utf8_lossy(&stream.stdout).to_string(); + let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(&format!("{k}="))).unwrap_or("").to_string(); + Some((["codec_name", "profile", "level", "pix_fmt"].map(field).join(","), types)) +} + +fn write(name: &str, es: &[u8]) -> PathBuf { + let path = fixture_dir().join(format!("enc_{name}.m2v")); + std::fs::write(&path, es).expect("write stream"); + path +} + +fn psnr_y(recon: &[ReconFrame], raw: &[u8], ys: usize, fsize: usize) -> f64 { + let mut s = Stats::default(); + for (r, f) in recon.iter().zip(raw.chunks_exact(fsize)) { + s.add(&r.y, &f[..ys]); + } + s.psnr() +} + +struct Case { + name: &'static str, + cfg: EncoderConfig, + frames: usize, + /// ffprobe's "codec,profile,level,pix_fmt". + probe: &'static str, + /// Lowest luma PSNR against the source. + min_psnr: f64, +} + +fn check(ff: &Path, c: &Case) -> (Vec, f64) { + let pix_fmt = pix_fmt_of(c.cfg.chroma); + let raw = source(ff, c.cfg.width, c.cfg.height, c.frames, pix_fmt); + let (ys, cs, _) = sizes(&c.cfg); + let fsize = ys + 2 * cs; + let (es, recon) = encode(&c.cfg, &raw); + let path = write(c.name, &es); + let decoded = ffmpeg_decode(ff, &path, pix_fmt); + assert_eq!(decoded.len(), c.frames * fsize, "{}: ffmpeg's frame count", c.name); + let mut all = Stats::default(); + for (i, (r, f)) in recon.iter().zip(decoded.chunks_exact(fsize)).enumerate() { + let mut s = Stats::default(); + s.add(&r.y, &f[..ys]); + s.add(&r.cb, &f[ys..ys + cs]); + s.add(&r.cr, &f[ys + cs..]); + assert!(s.max <= TOL_MAX && s.psnr() >= MIN_PSNR, "{} frame {i}: max {} psnr {:.2} against ffmpeg", c.name, s.max, s.psnr()); + all.merge(&s); + } + assert!((all.diff as f64) / (all.n as f64) <= MAX_DIFFERING, "{}: {} of {} samples differ from ffmpeg", c.name, all.diff, all.n); + let quality = psnr_y(&recon, &raw, ys, fsize); + assert!(quality >= c.min_psnr, "{}: luma PSNR {quality:.2} dB against the source", c.name); + if let Some((stream, types)) = ffprobe(&path) { + assert_eq!(stream, c.probe, "{}: ffprobe", c.name); + let gop = c.cfg.gop_size as usize; + let want: Vec<&str> = (0..c.frames).map(|i| if i % gop == 0 { "I" } else { "P" }).collect(); + assert_eq!(types, want, "{}: picture types", c.name); + } + let kbps = es.len() as f64 * 8.0 * 25.0 / c.frames as f64 / 1000.0; + println!( + "{}: {} frames, {kbps:.0} kbit/s, luma PSNR {quality:.2} dB, max diff to ffmpeg {}, {:.3}% differ", + c.name, + c.frames, + all.max, + 100.0 * all.diff as f64 / all.n as f64 + ); + (es, kbps) +} + +#[test] +fn ffmpeg_decodes_our_streams() { + let Some(ff) = craft_media_testkit::ffmpeg_or_skip("mpeg2 encoder oracle") else { return }; + let cif = EncoderConfig { gop_size: 12, ..EncoderConfig::new(352, 288, 25, 1) }; + let cases = [ + Case { name: "cif_q4", cfg: cif.clone(), frames: 25, probe: "mpeg2video,Main,10,yuv420p", min_psnr: 36.0 }, + Case { + name: "cif_intra", + cfg: EncoderConfig { gop_size: 1, rate: RateControl::Quantiser(8), ..cif.clone() }, + frames: 6, + probe: "mpeg2video,Main,10,yuv420p", + min_psnr: 32.0, + }, + Case { + name: "cif_linear_coarse", + cfg: EncoderConfig { nonlinear_quant: false, adaptive_quant: false, rate: RateControl::Quantiser(20), gop_size: 15, ..cif.clone() }, + frames: 20, + probe: "mpeg2video,Main,10,yuv420p", + min_psnr: 26.0, + }, + Case { + name: "sd_422", + cfg: EncoderConfig { chroma: ChromaFormat::Yuv422, intra_dc_bits: 10, ..EncoderConfig::new(720, 576, 25, 1) }, + frames: 8, + // 4:2:2@Main is indication 0x85; ffprobe prints its low four bits as the level + probe: "mpeg2video,4:2:2,5,yuv422p", + min_psnr: 36.0, + }, + ]; + for c in &cases { + check(&ff, c); + } +} + +#[test] +fn average_bitrate_reaches_its_target() { + let Some(ff) = craft_media_testkit::ffmpeg_or_skip("mpeg2 encoder bitrate") else { return }; + for kbps in [800u32, 2500] { + let c = Case { + name: if kbps == 800 { "cif_abr_800" } else { "cif_abr_2500" }, + cfg: EncoderConfig { rate: RateControl::Abr { kbps }, gop_size: 12, ..EncoderConfig::new(352, 288, 25, 1) }, + frames: 72, + probe: "mpeg2video,Main,10,yuv420p", + min_psnr: 26.0, + }; + let (_, got) = check(&ff, &c); + let err = got / f64::from(kbps) - 1.0; + assert!(err.abs() < 0.10, "{kbps} kbit/s target: got {got:.0} kbit/s ({:+.1}%)", 100.0 * err); + } +} diff --git a/crates/video/tests/mpeg2/main.rs b/crates/video/tests/mpeg2/main.rs index 1864b4a22..acbbf662e 100644 --- a/crates/video/tests/mpeg2/main.rs +++ b/crates/video/tests/mpeg2/main.rs @@ -1,4 +1,6 @@ mod common; +#[cfg(feature = "mpeg2-encode")] +mod encode; mod fuzz_hostile; mod hostile_dims; mod oracle; From f68e4369eb77475b6542a4814489ed11f7312edc Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 12 Nov 2026 18:41:44 +0100 Subject: [PATCH 008/118] craft-audio: decode the HE-AAC SBR high band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aac::Decoder::set_sbr(true) decodes HE-AAC at twice the core rate with the SBR tool of ISO/IEC 14496-3 §4.6.18: QMF analysis and synthesis, frequency band tables, patching with LPC inverse filtering, envelope adjustment with limiter, boost, noise and sinusoids. TrackDecoder uses it in place of the 2x upsampler. Tables are transcribed from ETSI ES 201 980 Annex I. Matches ffmpeg's decoder on AudioToolbox HE-AAC streams of 1-6 channels: the same 962-sample delay, ~54 dB SNR, high band level within 0.01 dB. Parametric stereo is still not decoded. Refs #1121 Co-Authored-By: Claude Opus 5.5 --- crates/audio/README.md | 13 +- crates/audio/src/aac/decoder.rs | 131 ++++- crates/audio/src/aac/huffman.rs | 6 +- crates/audio/src/aac/mod.rs | 22 +- crates/audio/src/aac/sbr/bitstream.rs | 489 +++++++++++++++++ crates/audio/src/aac/sbr/freq.rs | 353 ++++++++++++ crates/audio/src/aac/sbr/hf.rs | 332 ++++++++++++ crates/audio/src/aac/sbr/mod.rs | 513 ++++++++++++++++++ crates/audio/src/aac/sbr/qmf.rs | 155 ++++++ crates/audio/src/aac/sbr/tables.rs | 357 ++++++++++++ crates/audio/src/track.rs | 48 +- .../audio/tests/codecs/aac/he_aac_oracle.rs | 165 ++++++ crates/audio/tests/codecs/aac/mod.rs | 1 + 13 files changed, 2526 insertions(+), 59 deletions(-) create mode 100644 crates/audio/src/aac/sbr/bitstream.rs create mode 100644 crates/audio/src/aac/sbr/freq.rs create mode 100644 crates/audio/src/aac/sbr/hf.rs create mode 100644 crates/audio/src/aac/sbr/mod.rs create mode 100644 crates/audio/src/aac/sbr/qmf.rs create mode 100644 crates/audio/src/aac/sbr/tables.rs create mode 100644 crates/audio/tests/codecs/aac/he_aac_oracle.rs diff --git a/crates/audio/README.md b/crates/audio/README.md index 30f09ea57..abd3228f4 100644 --- a/crates/audio/README.md +++ b/crates/audio/README.md @@ -309,7 +309,8 @@ match craft_audio::decode(&bytes, None) { | AIFF, AIFF-C | native | native | | FLAC | symphonia | native | | AAC-LC | native | native | -| HE-AAC v1/v2 | native, core only (see [Limits](#limits)) | — | +| HE-AAC v1 | native (SBR) | — | +| HE-AAC v2 | native, without parametric stereo (see [Limits](#limits)) | — | | ALAC | native | native, packets (`alac::Encoder`; no file writer) | | MP1, MP2 (MPEG audio Layer I/II) | native | — | | MP3 (Layer III) | native | native, frames (`mp3::Encoder`; the frames in a row are an `.mp3` file) | @@ -329,10 +330,10 @@ match craft_audio::decode(&bytes, None) { ## Limits -- HE-AAC: the AAC-LC core is decoded. `decode`, `Decoder` and `TrackDecoder` upsample it 2×; - `aac::Decoder` alone outputs the core rate. SBR is not reconstructed, so there is no content - above the core's Nyquist frequency. -- HE-AAC v2: parametric stereo is not reconstructed either, so the mono core decodes as mono. +- HE-AAC: `decode`, `Decoder` and `TrackDecoder` decode SBR at twice the core rate. + `aac::Decoder` outputs the core rate unless `set_sbr(true)` is called. Downsampled SBR (an + output rate equal to the core rate) plays the core alone. +- HE-AAC v2: parametric stereo is not reconstructed, so the mono core decodes as mono, with SBR. - AAC: Main, SSR and LTP profiles, coupling channels and 960-sample frames are unsupported. - Raw ADTS: frames with several raw data blocks are unsupported: a stream of them is refused, and one among other frames decodes as silence of its length and counts as damaged, as does a frame @@ -413,7 +414,7 @@ Each codec module's rustdoc lists the coding tools it implements. |---|---|---|---| | WAV, AIFF | Microsoft RIFF, Apple AIFF/AIFF-C | read: integer 8–32-bit, float 32/64-bit, `WAVE_FORMAT_EXTENSIBLE` masks, RF64/BW64, BWF `bext`, truncated files; write: 16/24/32-bit integer and 32-bit float, RF64 above 4 GiB, any layout, TPDF dither | round trips; hostile-input tests | | FLAC encoder | RFC 9639 | 16, 24, 32 bits; 1–8 channels, layouts decoders would not assume for the count as a `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment; up to 655,350 Hz; LPC, stereo decorrelation, wasted bits, STREAMINFO MD5 | bit-exact through ffmpeg, STREAMINFO MD5 checked against ffmpeg | -| AAC (`aac`) | ISO/IEC 14496-3 | AAC-LC decode and encode (CBR, VBR, ADTS), 1–8 channels; HE-AAC: see [Limits](#limits) | ffmpeg: ~135 dB SNR; PNS noise differs per sample at equal band energy | +| AAC (`aac`) | ISO/IEC 14496-3; SBR tables from ETSI ES 201 980 Annex I | AAC-LC decode and encode (CBR, VBR, ADTS), 1–8 channels; HE-AAC (SBR) decode, see [Limits](#limits) | ffmpeg: ~135 dB SNR; PNS noise differs per sample at equal band energy. HE-AAC from AudioToolbox, 1–6 channels: same 962-sample delay as ffmpeg, ~54 dB SNR, high band level within 0.01 dB | | Opus (`opus`) | RFC 6716, 8251, 7845 | decoder: SILK, hybrid, CELT, all frame sizes, PLC and FEC, multistream (`Decoder`) and single stream (`StreamDecoder`), 8–48 kHz output | all 36 RFC 8251 conformance cases | | AC-3/E-AC-3 (`ac3`) | decoder: ATSC A/52:2012; encoder/programme API: ETSI TS 102 366 V1.4.1 | single-syncframe and complete-programme decoding; packet encoding through discrete 7.1 (see below) | decoder comparisons and encoder output through ffmpeg, CRC/channel/timing/bit-budget regressions; synthetic quality limitations below | | MP1/MP2 (`mpa`) | ISO/IEC 11172-3, 13818-3 | Layer I and II, MPEG-1/2/2.5, 8–48 kHz; also the Layer II rate/mode pairs the spec disallows (ffmpeg's default mono 384 kbit/s); no free format | ffmpeg-encoded streams, 73–84 dB SNR | diff --git a/crates/audio/src/aac/decoder.rs b/crates/audio/src/aac/decoder.rs index 8c4d41300..1d8713f54 100644 --- a/crates/audio/src/aac/decoder.rs +++ b/crates/audio/src/aac/decoder.rs @@ -6,6 +6,7 @@ use crate::aac::config::{AudioSpecificConfig, ElementType, ProgramConfig}; use crate::aac::huffman::{self, INTENSITY_HCB, INTENSITY_HCB2, NOISE_HCB, ZERO_HCB}; use crate::aac::ics::{IcsInfo, WindowSequence}; use crate::aac::mdct::{MdctScratch, WindowShape, mdct_long, mdct_short, window}; +use crate::aac::sbr; use crate::aac::tables::{pow43, pow43_table, sf_gain}; use crate::aac::tns::{self, TnsData}; use crate::aac::{Error, Result}; @@ -63,7 +64,14 @@ impl Scratch { } } -/// AAC-LC decoder. +/// Where the SBR data of a channel element sits in its access unit: the bit range of the fill +/// element's payload after its extension type, and whether a CRC leads it. +type SbrRef = (usize, usize, bool); + +/// An access unit's core output, and the SBR data of each layout entry. +type Decoded = (Vec>, Vec>); + +/// AAC-LC decoder, with the SBR tool of HE-AAC when [`Decoder::set_sbr`] turns it on. #[derive(Clone)] pub struct Decoder { asc: AudioSpecificConfig, @@ -75,6 +83,8 @@ pub struct Decoder { scratch: Scratch, /// A fill element carried SBR data (implicitly signalled HE-AAC). sbr_seen: bool, + /// The SBR decoder of each layout entry, when output is at twice the core rate. + sbr_out: Option>, } impl Decoder { @@ -110,6 +120,7 @@ impl Decoder { noise_state: NOISE_SEED, scratch: Scratch::new(), sbr_seen: false, + sbr_out: None, }; d.set_layout(layout); Ok(d) @@ -131,16 +142,27 @@ impl Decoder { pub fn config(&self) -> &AudioSpecificConfig { &self.asc } - /// Output sampling rate in Hz: the AAC-LC core rate. + /// Output sampling rate in Hz: the AAC-LC core rate, or twice it with [`Self::set_sbr`] on. pub fn sample_rate(&self) -> u32 { - self.asc.sample_rate + if self.sbr_out.is_some() { self.asc.sample_rate.saturating_mul(2) } else { self.asc.sample_rate } } /// Whether the stream is HE-AAC: SBR signalled in the `AudioSpecificConfig` (explicit) or SBR - /// data seen in a fill element of a decoded access unit (implicit). Only the AAC-LC core is - /// decoded, at [`Self::sample_rate`]; the SBR high band is not reconstructed. + /// data seen in a fill element of a decoded access unit (implicit). Without [`Self::set_sbr`], + /// only the AAC-LC core is decoded. pub fn sbr(&self) -> bool { self.asc.sbr || self.sbr_seen } + /// Decode HE-AAC at twice the core rate (`true`) or the AAC-LC core alone (`false`, the + /// default). With SBR on, [`Self::decode`] returns 2048 samples per channel: the core through + /// the SBR filter banks, delayed by [`SBR_DELAY`](crate::aac::SBR_DELAY) samples, with the high + /// band rebuilt from each frame's SBR data. Until the stream's first SBR header, and for a + /// frame whose SBR data is missing or damaged, the high band is empty. Parametric stereo + /// (HE-AAC v2) is not decoded: its mono core stays mono. + pub fn set_sbr(&mut self, on: bool) { + if on != self.sbr_out.is_some() { + self.sbr_out = on.then(Vec::new); + } + } /// Output channels of the current element layout. pub fn channels(&self) -> usize { self.channels.len() @@ -158,30 +180,60 @@ impl Decoder { c.overlap.fill(0.0); c.prev_shape = WindowShape::Sine; } + for e in self.sbr_out.iter_mut().flatten() { + e.reset(); + } } fn sf_index(&self) -> u8 { self.asc.sf_index } - /// Decode one access unit (one `raw_data_block`) into 1024 samples per channel. An access unit - /// that fails leaves the decoder as it was, but for [`Self::sbr`]: its damage, including a damaged - /// program config element, does not reach the access units after it. + /// Decode one access unit (one `raw_data_block`) into 1024 samples per channel (2048 with + /// [`Self::set_sbr`] on). An access unit that fails leaves the decoder as it was, but for + /// [`Self::sbr`]: its damage, including a damaged program config element, does not reach the + /// access units after it. Damaged SBR data alone does not fail it. pub fn decode(&mut self, au: &[u8]) -> Result>> { let before = (self.asc.pce.clone(), self.layout.clone(), self.offsets.clone(), self.channels.clone(), self.noise_state); - let decoded = self.decode_block(au, true); - if decoded.is_err() { - (self.asc.pce, self.layout, self.offsets, self.channels, self.noise_state) = before; + match self.decode_block(au, true) { + Ok((pcm, sbr_refs)) => Ok(self.apply_sbr(au, pcm, &sbr_refs)), + Err(e) => { + (self.asc.pce, self.layout, self.offsets, self.channels, self.noise_state) = before; + Err(e) + } } - decoded } - fn decode_block(&mut self, au: &[u8], allow_reconfig: bool) -> Result>> { + /// Run each layout entry's channels through its SBR decoder, when SBR is on. + fn apply_sbr(&mut self, au: &[u8], pcm: Vec>, refs: &[Option]) -> Vec> { + let rate = self.sample_rate(); + let Some(elements) = self.sbr_out.as_mut() else { + return pcm; + }; + // a new layout (a program config element) starts the SBR decoders afresh + let fits = elements.len() == self.layout.len() && elements.iter().zip(&self.layout).all(|(e, l)| e.channels() == l.0.channels()); + if !fits { + *elements = self.layout.iter().map(|l| sbr::Element::new(l.0.channels(), rate)).collect(); + } + let mut out = Vec::with_capacity(pcm.len()); + for (i, e) in elements.iter_mut().enumerate() { + let first = self.offsets.get(i).copied().unwrap_or(0); + let core = pcm.get(first..first + e.channels()).unwrap_or_default(); + let payload = refs.get(i).copied().flatten().map(|(start, end, crc)| sbr::Payload { data: au, start, end, crc }); + out.extend(e.process(core, payload)); + } + out + } + + fn decode_block(&mut self, au: &[u8], allow_reconfig: bool) -> Result { let mut br = BitReader::new(au); let nch = self.channels.len(); let mut saw_element = false; let mut out: Vec>> = vec![None; nch]; let mut used = vec![false; self.layout.len()]; + // SBR data belongs to the single channel or pair element before its fill element + let mut sbr_refs: Vec> = vec![None; self.layout.len()]; + let mut last_slot = None; loop { let id = br.read_bits(3)?; saw_element |= matches!(id, ID_SCE | ID_CPE | ID_LFE); @@ -191,6 +243,7 @@ impl Decoder { let ty = if id == ID_SCE { ElementType::Sce } else { ElementType::Lfe }; let slot = self.find_slot(ty, tag, &used)?; used[slot] = true; + last_slot = (id == ID_SCE).then_some(slot); let mut ics = self.decode_ics(&mut br, None)?; let ch = self.offsets[slot]; self.fill_noise(&mut ics, None); @@ -200,6 +253,7 @@ impl Decoder { let tag = br.read_bits(4)? as u8; let slot = self.find_slot(ElementType::Cpe, tag, &used)?; used[slot] = true; + last_slot = Some(slot); let ch = self.offsets[slot]; let [a, b] = self.decode_cpe(&mut br, ch)?; out[ch] = Some(a); @@ -237,8 +291,13 @@ impl Decoder { } if count > 0 { let ext = br.read_bits(4)?; - self.sbr_seen |= matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC); + let is_sbr = matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC); + self.sbr_seen |= is_sbr; + let start = br.position(); br.skip(count * 8 - 4)?; + if let (true, Some(slot)) = (is_sbr, last_slot.take()) { + sbr_refs[slot] = Some((start, br.position(), ext == EXT_SBR_DATA_CRC)); + } } } _ => break, // ID_END @@ -251,7 +310,7 @@ impl Decoder { for o in out { res.push(o.ok_or(Error::Bitstream("access unit is missing a channel element"))?); } - Ok(res) + Ok((res, sbr_refs)) } fn find_slot(&self, ty: ElementType, tag: u8, used: &[bool]) -> Result { @@ -637,6 +696,48 @@ mod tests { assert_eq!(dec.sample_rate(), 22_050); } + /// With SBR on, an access unit decodes to 2048 samples, and over a silent core the high band + /// its SBR fill element describes is there; with SBR off, the core alone (silence). + #[test] + fn sbr_fill_elements_rebuild_the_high_band() { + let payload = sbr::test_payload(44_100, |_| 40, 0).unwrap(); + let mut bw = BitWriter::new(); + bw.write_bits(ID_SCE, 3); + bw.write_bits(0, 4); + bw.write_bits(0, 8 + 11 + 3); // global_gain, ics_info (max_sfb 0), no pulse/TNS/gain control + bw.write_bits(ID_FIL, 3); + let count = (4 + 8 * payload.len()).div_ceil(8); + if count < 15 { + bw.write_bits(count as u32, 4); + } else { + bw.write_bits(15, 4); + bw.write_bits((count - 14) as u32, 8); + } + bw.write_bits(EXT_SBR_DATA, 4); + for &b in &payload { + bw.write_bits(u32::from(b), 8); + } + bw.write_bits(0, 4); + bw.write_bits(ID_END, 3); + let au = bw.finish(); + // AAC-LC, 22.05 kHz, mono + let asc = AudioSpecificConfig::lc(7, 1); + let mut dec = Decoder::from_config(asc.clone()).unwrap(); + let core = dec.decode(&au).unwrap(); + assert_eq!((core[0].len(), dec.sample_rate()), (1024, 22_050)); + assert!(dec.sbr() && core[0].iter().all(|&s| s == 0.0)); + let mut dec = Decoder::from_config(asc).unwrap(); + dec.set_sbr(true); + assert_eq!(dec.sample_rate(), 44_100); + let mut out = Vec::new(); + for _ in 0..4 { + out.extend(dec.decode(&au).unwrap().remove(0)); + } + assert_eq!(out.len(), 4 * 2048); + let rms = (out[2048..].iter().map(|s| s * s).sum::() / (out.len() - 2048) as f32).sqrt(); + assert!(rms > 1e-4, "{rms}"); + } + /// An access unit whose elements are all empty: `elements` are (id, tag), then ID_END. fn silent_au(pce: Option<&ProgramConfig>, elements: &[(u32, u32)]) -> Vec { let mut bw = BitWriter::new(); diff --git a/crates/audio/src/aac/huffman.rs b/crates/audio/src/aac/huffman.rs index adac5c9c9..0c29673e7 100644 --- a/crates/audio/src/aac/huffman.rs +++ b/crates/audio/src/aac/huffman.rs @@ -62,12 +62,12 @@ const SUB: u32 = 1 << 31; /// entry with `SUB` set is `SUB | offset << 5 | width` and selects a sub-table of `1 << width` /// leaves indexed by the next `width` bits. An entry of 0 is a bit pattern that no codeword starts /// with (the codebooks are complete, so this does not occur). -struct Lut { +pub(crate) struct Lut { t: Vec, } impl Lut { - fn build(codes: &[(u32, u8)]) -> Lut { + pub(crate) fn build(codes: &[(u32, u8)]) -> Lut { let mut t = vec![0u32; 1 << L1]; // sub-table width per first-level prefix let mut width = vec![0u32; 1 << L1]; @@ -103,7 +103,7 @@ impl Lut { /// The symbol of the codeword at the reader position; `Eof` if the codeword is cut off. #[inline] - fn decode(&self, br: &mut BitReader) -> Result { + pub(crate) fn decode(&self, br: &mut BitReader) -> Result { let bits = br.peek(32); let mut e = self.t[(bits >> (32 - L1)) as usize]; if e & SUB != 0 { diff --git a/crates/audio/src/aac/mod.rs b/crates/audio/src/aac/mod.rs index 0a5f639a4..297aa7972 100644 --- a/crates/audio/src/aac/mod.rs +++ b/crates/audio/src/aac/mod.rs @@ -5,13 +5,13 @@ //! model, window switching, M/S, TNS, bit reservoir, CBR/VBR, 1–8 channels. //! - [`Decoder`] (feature `aac-decode`): `AudioSpecificConfig` + raw access units → planar f32. //! Supports M/S, intensity stereo, PNS, TNS, pulse data, both window shapes and every window -//! sequence. +//! sequence, and the SBR tool of HE-AAC ([`Decoder::set_sbr`]). //! - [`latm_config`] / [`latm_split`] (feature `aac-decode`): the configuration and payload of //! LOAS / LATM frames (AAC in MPEG transport streams), for the decoder. //! -//! HE-AAC (SBR/PS), Main/SSR/LTP profiles, coupling channels and 960-sample frames are not -//! supported; for HE-AAC only the AAC-LC core is decoded (at the core rate; [`Decoder::sbr`] says -//! when). +//! Parametric stereo (HE-AAC v2), Main/SSR/LTP profiles, coupling channels and 960-sample frames +//! are not supported. HE-AAC decodes at twice the core rate with [`Decoder::set_sbr`] on +//! ([`Decoder::sbr`] says when a stream uses SBR), and as its AAC-LC core otherwise. //! //! From FilmCraft's `filmcraft-aac` (storytold/filmcraft@8fda2f2). Public items keep their names, //! so FilmCraft can re-export this module. @@ -53,6 +53,8 @@ mod ics; #[cfg(feature = "aac-decode")] mod latm; mod mdct; +#[cfg(feature = "aac-decode")] +mod sbr; mod tables; mod tns; @@ -68,6 +70,12 @@ pub use latm::{LatmConfig, latm_config, latm_split}; pub use mdct::WindowShape; pub use tables::{SAMPLE_RATES, sample_rate_index}; +/// Output samples by which [`Decoder`] with [`Decoder::set_sbr`] on delays the core: the SBR filter +/// banks and the SBR frame's lag behind the core frame. HE-AAC encoders count it in the priming the +/// container signals. +#[cfg(feature = "aac-decode")] +pub const SBR_DELAY: usize = sbr::DELAY; + /// Samples per AAC-LC frame (per channel). pub const FRAME_LEN: usize = 1024; @@ -197,9 +205,9 @@ const UPSAMPLE_DELAY: usize = 481; #[cfg(feature = "aac-decode")] const UPSAMPLE_HISTORY: usize = UPSAMPLE_DELAY + UPSAMPLE_HALF; -/// 2× upsampler for HE-AAC. The decoder reconstructs the AAC-LC core at half the output rate and -/// not yet the SBR high band, so the core is interpolated to the output rate: timing and pitch are -/// right and the band above the core's Nyquist stays empty. Even outputs are the input samples, +/// 2× upsampler: interpolates an AAC-LC core to twice its rate, leaving the band above the core's +/// Nyquist empty. [`Decoder::set_sbr`] decodes HE-AAC with its high band instead; this stays for +/// callers that want the core alone at the output rate. Even outputs are the input samples, /// odd ones a Blackman-windowed sinc half-band interpolation over 2 × `UPSAMPLE_HALF` inputs. /// The output is delayed by `UPSAMPLE_DELAY` input samples, as an SBR decoder's is; the state /// carries across packets, so a seek primed with the preceding packet gives the same samples as diff --git a/crates/audio/src/aac/sbr/bitstream.rs b/crates/audio/src/aac/sbr/bitstream.rs new file mode 100644 index 000000000..6233504fa --- /dev/null +++ b/crates/audio/src/aac/sbr/bitstream.rs @@ -0,0 +1,489 @@ +//! The SBR bitstream (ISO/IEC 14496-3 §4.4.2.8 syntax, §4.5.2.8 semantics): the header and the +//! single and channel pair elements of an `EXT_SBR_DATA` fill element. + +use std::sync::OnceLock; + +use craft_media::bitstream::BitReader; + +use super::freq::FreqTables; +use super::tables::{ + F_ENV_1_5DB, F_ENV_3_0DB, F_ENV_BAL_1_5DB, F_ENV_BAL_3_0DB, T_ENV_1_5DB, T_ENV_3_0DB, T_ENV_BAL_1_5DB, T_ENV_BAL_3_0DB, T_NOISE_3_0DB, + T_NOISE_BAL_3_0DB, +}; +use super::{Header, MAX_ENV}; +use crate::aac::huffman::Lut; +use crate::aac::{Error, Result}; + +/// `bs_frame_class`: fixed leading and trailing frame borders. +pub(crate) const FIXFIX: u8 = 0; +/// Fixed leading border, variable trailing border. +pub(crate) const FIXVAR: u8 = 1; +/// Variable leading border, fixed trailing border. +pub(crate) const VARFIX: u8 = 2; +/// Variable leading and trailing borders. +pub(crate) const VARVAR: u8 = 3; + +/// SBR time slots per frame (`numTimeSlots`; two QMF slots each). +const TIME_SLOTS: usize = 16; + +/// Decoding tables for the SBR Huffman codes, in [`Book`] order. +fn luts() -> &'static [Lut; 10] { + static LUTS: OnceLock<[Lut; 10]> = OnceLock::new(); + LUTS.get_or_init(|| { + [ + Lut::build(&T_ENV_1_5DB), + Lut::build(&F_ENV_1_5DB), + Lut::build(&T_ENV_BAL_1_5DB), + Lut::build(&F_ENV_BAL_1_5DB), + Lut::build(&T_ENV_3_0DB), + Lut::build(&F_ENV_3_0DB), + Lut::build(&T_ENV_BAL_3_0DB), + Lut::build(&F_ENV_BAL_3_0DB), + Lut::build(&T_NOISE_3_0DB), + Lut::build(&T_NOISE_BAL_3_0DB), + ] + }) +} + +/// One of the SBR Huffman codes, and its largest absolute value. +#[derive(Clone, Copy)] +struct Book(usize, i32); + +const T_ENV_15: Book = Book(0, 60); +const F_ENV_15: Book = Book(1, 60); +const T_BAL_15: Book = Book(2, 24); +const F_BAL_15: Book = Book(3, 24); +const T_ENV_30: Book = Book(4, 31); +const F_ENV_30: Book = Book(5, 31); +const T_BAL_30: Book = Book(6, 12); +const F_BAL_30: Book = Book(7, 12); +const T_NOISE: Book = Book(8, 31); +const T_NOISE_BAL: Book = Book(9, 12); + +fn huff(br: &mut BitReader, book: Book) -> Result { + Ok(luts()[book.0].decode(br)? as i32 - book.1) +} + +fn bits(br: &mut BitReader, n: u32) -> Result { + Ok(br.read_bits(n)? as usize) +} + +impl Header { + /// `sbr_header()`, with the defaults of the fields its extra parts leave out. + pub(crate) fn read(br: &mut BitReader) -> Result
{ + let amp_res = br.read_flag()?; + let start_freq = br.read_bits(4)? as u8; + let stop_freq = br.read_bits(4)? as u8; + let xover_band = br.read_bits(3)? as u8; + br.skip(2)?; // bs_reserved + let (extra_1, extra_2) = (br.read_flag()?, br.read_flag()?); + let mut h = Header { + amp_res, + start_freq, + stop_freq, + xover_band, + freq_scale: 2, + alter_scale: true, + noise_bands: 2, + limiter_bands: 2, + limiter_gains: 2, + interpol_freq: true, + smoothing_mode: true, + }; + if extra_1 { + h.freq_scale = br.read_bits(2)? as u8; + h.alter_scale = br.read_flag()?; + h.noise_bands = br.read_bits(2)? as u8; + } + if extra_2 { + h.limiter_bands = br.read_bits(2)? as u8; + h.limiter_gains = br.read_bits(2)? as u8; + h.interpol_freq = br.read_flag()?; + h.smoothing_mode = br.read_flag()?; + } + Ok(h) + } +} + +/// The time/frequency grid of one channel in one frame (`sbr_grid()`, §4.6.18.3.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Grid { + pub(crate) class: u8, + /// Envelopes (1 to 5). + pub(crate) num_env: usize, + /// Envelope borders in time slots; `t_env[num_env]` may reach past the frame (up to 19). + pub(crate) t_env: [usize; MAX_ENV + 1], + /// Noise floors (1 or 2). + pub(crate) num_noise: usize, + /// Noise floor borders. + pub(crate) t_noise: [usize; 3], + /// High frequency resolution per envelope. + pub(crate) freq_res: [bool; MAX_ENV], + /// The transient envelope `l_A`, if any (it may be `num_env`: the next frame's first). + pub(crate) la: Option, + /// 3 dB envelope steps (the header's choice, but 1.5 dB for a single FIXFIX envelope). + pub(crate) amp_res: bool, +} + +/// `ceil(log2(num_env + 1))`: the width of `bs_pointer`. +fn pointer_bits(num_env: usize) -> u32 { + usize::BITS - num_env.leading_zeros() +} + +fn read_rel(br: &mut BitReader, n: usize) -> Result<[usize; 3]> { + let mut rel = [0; 3]; + for r in rel.iter_mut().take(n) { + *r = 2 * bits(br, 2)? + 2; + } + Ok(rel) +} + +impl Grid { + fn read(br: &mut BitReader, amp_res: bool) -> Result { + let bad = || Error::Bitstream("invalid SBR time grid"); + let class = br.read_bits(2)? as u8; + let mut t = [0i64; MAX_ENV + 1]; + let mut freq_res = [false; MAX_ENV]; + let (n, pointer) = match class { + FIXFIX => { + let n = 1usize << br.read_bits(2)?; + if n > 4 { + return Err(bad()); + } + freq_res = [br.read_flag()?; MAX_ENV]; + for (l, t) in t.iter_mut().enumerate().take(n + 1) { + *t = (l * TIME_SLOTS / n) as i64; + } + (n, 0) + } + FIXVAR | VARFIX => { + let abs = bits(br, 2)?; + let rels = bits(br, 2)?; + let n = rels + 1; + let rel = read_rel(br, rels)?; + let pointer = bits(br, pointer_bits(n))?; + for l in 0..n { + freq_res[if class == FIXVAR { n - 1 - l } else { l }] = br.read_flag()?; + } + if class == FIXVAR { + t[n] = (abs + TIME_SLOTS) as i64; + for l in (1..n).rev() { + t[l] = t[l + 1] - rel[n - 1 - l] as i64; + } + } else { + (t[0], t[n]) = (abs as i64, TIME_SLOTS as i64); + for l in 1..n { + t[l] = t[l - 1] + rel[l - 1] as i64; + } + } + (n, pointer) + } + _ => { + let abs0 = bits(br, 2)?; + let abs1 = bits(br, 2)? + TIME_SLOTS; + let (rels0, rels1) = (bits(br, 2)?, bits(br, 2)?); + let n = rels0 + rels1 + 1; + if n > MAX_ENV { + return Err(bad()); + } + let (rel0, rel1) = (read_rel(br, rels0)?, read_rel(br, rels1)?); + let pointer = bits(br, pointer_bits(n))?; + for r in freq_res.iter_mut().take(n) { + *r = br.read_flag()?; + } + (t[0], t[n]) = (abs0 as i64, abs1 as i64); + for l in 1..=rels0 { + t[l] = t[l - 1] + rel0[l - 1] as i64; + } + for l in (rels0 + 1..n).rev() { + t[l] = t[l + 1] - rel1[n - 1 - l] as i64; + } + (n, pointer) + } + }; + if t[0] < 0 || t[..=n].windows(2).any(|w| w[0] >= w[1]) { + return Err(bad()); + } + let la = match (pointer, class) { + (0, _) => None, + (p, FIXVAR | VARVAR) => Some((n + 1).checked_sub(p).ok_or_else(bad)?), + (p, _) => Some(p - 1), + }; + if la.is_some_and(|la| la > n) { + return Err(bad()); + } + let t_env: [usize; MAX_ENV + 1] = std::array::from_fn(|i| t[i] as usize); + let (num_noise, t_noise) = if n == 1 { + (1, [t_env[0], t_env[1], 0]) + } else { + let middle = match (class, pointer) { + (FIXFIX, _) => n / 2, + (VARFIX, 0) => 1, + (VARFIX, 1) => n - 1, + (VARFIX, p) => p - 1, + (_, 0 | 1) => n - 1, + (_, p) => (n + 1).checked_sub(p).ok_or_else(bad)?, + }; + if middle == 0 || middle >= n { + return Err(bad()); + } + (2, [t_env[0], t_env[middle], t_env[n]]) + }; + Ok(Grid { class, num_env: n, t_env, num_noise, t_noise, freq_res, la, amp_res: amp_res && !(class == FIXFIX && n == 1) }) + } +} + +/// The data of one channel as transmitted: delta-coded envelope and noise floor values (the +/// first frequency-coded value of each is absolute), not yet scaled for a balance channel. +#[derive(Clone, Debug)] +pub(crate) struct ChannelData { + pub(crate) grid: Grid, + pub(crate) df_env: [bool; MAX_ENV], + pub(crate) df_noise: [bool; 2], + pub(crate) invf: [u8; 5], + pub(crate) env: [[i32; 64]; MAX_ENV], + pub(crate) noise: [[i32; 5]; 2], + /// `bs_add_harmonic` per high-resolution band, when `bs_add_harmonic_flag` is set. + pub(crate) harmonic: Option<[bool; 64]>, +} + +impl ChannelData { + fn new(grid: Grid) -> ChannelData { + ChannelData { + grid, + df_env: [false; MAX_ENV], + df_noise: [false; 2], + invf: [0; 5], + env: [[0; 64]; MAX_ENV], + noise: [[0; 5]; 2], + harmonic: None, + } + } + + fn read_dtdf(&mut self, br: &mut BitReader) -> Result<()> { + for d in self.df_env.iter_mut().take(self.grid.num_env) { + *d = br.read_flag()?; + } + for d in self.df_noise.iter_mut().take(self.grid.num_noise) { + *d = br.read_flag()?; + } + Ok(()) + } + + fn read_invf(&mut self, br: &mut BitReader, t: &FreqTables) -> Result<()> { + for m in self.invf.iter_mut().take(t.n_q()) { + *m = br.read_bits(2)? as u8; + } + Ok(()) + } + + /// `sbr_envelope()`; `balance` for the second channel of a coupled pair. + fn read_envelope(&mut self, br: &mut BitReader, t: &FreqTables, balance: bool) -> Result<()> { + let (t_book, f_book, start_bits) = match (self.grid.amp_res, balance) { + (false, false) => (T_ENV_15, F_ENV_15, 7), + (false, true) => (T_BAL_15, F_BAL_15, 6), + (true, false) => (T_ENV_30, F_ENV_30, 6), + (true, true) => (T_BAL_30, F_BAL_30, 5), + }; + for l in 0..self.grid.num_env { + let n = t.bands(self.grid.freq_res[l]).len() - 1; + let row = &mut self.env[l]; + if self.df_env[l] { + for v in row.iter_mut().take(n) { + *v = huff(br, t_book)?; + } + } else { + row[0] = br.read_bits(start_bits)? as i32; + for v in row.iter_mut().take(n).skip(1) { + *v = huff(br, f_book)?; + } + } + } + Ok(()) + } + + /// `sbr_noise()`. + fn read_noise(&mut self, br: &mut BitReader, t: &FreqTables, balance: bool) -> Result<()> { + let (t_book, f_book) = if balance { (T_NOISE_BAL, F_BAL_30) } else { (T_NOISE, F_ENV_30) }; + for l in 0..self.grid.num_noise { + let row = &mut self.noise[l]; + if self.df_noise[l] { + for v in row.iter_mut().take(t.n_q()) { + *v = huff(br, t_book)?; + } + } else { + row[0] = br.read_bits(5)? as i32; + for v in row.iter_mut().take(t.n_q()).skip(1) { + *v = huff(br, f_book)?; + } + } + } + Ok(()) + } + + /// `bs_add_harmonic_flag` and `sbr_sinusoidal_coding()`. + fn read_harmonics(&mut self, br: &mut BitReader, t: &FreqTables) -> Result<()> { + if br.read_flag()? { + let mut h = [false; 64]; + for v in h.iter_mut().take(t.n_high()) { + *v = br.read_flag()?; + } + self.harmonic = Some(h); + } + Ok(()) + } +} + +/// Skip `bs_extended_data` (parametric stereo, for one, is not decoded). +fn skip_extended(br: &mut BitReader) -> Result<()> { + if br.read_flag()? { + let mut count = bits(br, 4)?; + if count == 15 { + count += bits(br, 8)?; + } + br.skip(8 * count)?; + } + Ok(()) +} + +/// The SBR data of one channel element. +#[derive(Clone, Debug)] +pub(crate) struct Frame { + /// The pair codes its second channel as a balance against the first. + pub(crate) coupling: bool, + pub(crate) channels: Vec, +} + +/// `sbr_single_channel_element()` (`channels` 1) or `sbr_channel_pair_element()` (2). +pub(crate) fn read_data(br: &mut BitReader, t: &FreqTables, amp_res: bool, channels: usize) -> Result { + if br.read_flag()? { + br.skip(4 * channels)?; // bs_reserved + } + if channels == 1 { + let mut c = ChannelData::new(Grid::read(br, amp_res)?); + c.read_dtdf(br)?; + c.read_invf(br, t)?; + c.read_envelope(br, t, false)?; + c.read_noise(br, t, false)?; + c.read_harmonics(br, t)?; + skip_extended(br)?; + return Ok(Frame { coupling: false, channels: vec![c] }); + } + let coupling = br.read_flag()?; + let (mut a, mut b); + if coupling { + a = ChannelData::new(Grid::read(br, amp_res)?); + b = ChannelData::new(a.grid.clone()); + a.read_dtdf(br)?; + b.read_dtdf(br)?; + a.read_invf(br, t)?; + b.invf = a.invf; + a.read_envelope(br, t, false)?; + a.read_noise(br, t, false)?; + b.read_envelope(br, t, true)?; + b.read_noise(br, t, true)?; + } else { + a = ChannelData::new(Grid::read(br, amp_res)?); + b = ChannelData::new(Grid::read(br, amp_res)?); + a.read_dtdf(br)?; + b.read_dtdf(br)?; + a.read_invf(br, t)?; + b.read_invf(br, t)?; + a.read_envelope(br, t, false)?; + b.read_envelope(br, t, false)?; + a.read_noise(br, t, false)?; + b.read_noise(br, t, false)?; + } + a.read_harmonics(br, t)?; + b.read_harmonics(br, t)?; + skip_extended(br)?; + Ok(Frame { coupling, channels: vec![a, b] }) +} + +#[cfg(test)] +mod tests { + use craft_media::bitstream::BitWriter; + + use super::*; + + /// Every SBR Huffman table is a complete prefix code, with the shortest codeword for a zero + /// delta, and its decoder reads back every codeword. + #[test] + fn huffman_tables_are_complete_prefix_codes() { + let tables: [&[(u32, u8)]; 10] = [ + &T_ENV_1_5DB, + &F_ENV_1_5DB, + &T_ENV_BAL_1_5DB, + &F_ENV_BAL_1_5DB, + &T_ENV_3_0DB, + &F_ENV_3_0DB, + &T_ENV_BAL_3_0DB, + &F_ENV_BAL_3_0DB, + &T_NOISE_3_0DB, + &T_NOISE_BAL_3_0DB, + ]; + let lavs = [60, 60, 24, 24, 31, 31, 12, 12, 31, 12]; + for (i, (codes, lav)) in tables.iter().zip(lavs).enumerate() { + assert_eq!(codes.len(), 2 * lav + 1, "table {i}"); + let max = u32::from(codes.iter().map(|c| c.1).max().unwrap()); + assert_eq!(codes.iter().map(|&(_, l)| 1u64 << (max - u32::from(l))).sum::(), 1 << max, "table {i}: Kraft sum"); + let shortest = codes.iter().map(|c| c.1).min().unwrap(); + assert_eq!(codes[lav].1, shortest, "table {i}"); + for (sym, &(code, len)) in codes.iter().enumerate() { + let mut bw = BitWriter::new(); + bw.write_bits(code, u32::from(len)); + bw.write_bits(0x5A5A5A, 24); + let data = bw.finish(); + let mut br = BitReader::new(&data); + assert_eq!(huff(&mut br, Book(i, lav as i32)).unwrap(), sym as i32 - lav as i32, "table {i}"); + assert_eq!(br.position(), usize::from(len)); + } + } + } + + fn grid(bits: &[(u32, u32)]) -> Result { + let mut bw = BitWriter::new(); + for &(v, n) in bits { + bw.write_bits(v, n); + } + bw.write_bits(0, 16); + let data = bw.finish(); + Grid::read(&mut BitReader::new(&data), true) + } + + /// The four frame classes give the borders, noise floors and transient envelope of + /// §4.6.18.3.3; a FIXFIX frame of one envelope uses 1.5 dB steps. + #[test] + fn grids_of_each_frame_class() { + // FIXFIX, 4 envelopes, high resolution + let g = grid(&[(FIXFIX as u32, 2), (2, 2), (1, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..5], g.num_noise, g.t_noise, g.la, g.amp_res), (4, &[0, 4, 8, 12, 16][..], 2, [0, 8, 16], None, true)); + let g = grid(&[(FIXFIX as u32, 2), (0, 2), (0, 1)]).unwrap(); + assert_eq!((g.num_env, g.t_noise, g.amp_res), (1, [0, 16, 0], false)); + // FIXVAR: trailing border 16 + 2, two relative borders (4, 2) back from it, pointer 1, + // resolutions sent last envelope first + let g = grid(&[(FIXVAR as u32, 2), (2, 2), (2, 2), (1, 2), (0, 2), (1, 2), (0, 1), (1, 1), (1, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..4]), (3, &[0, 12, 14, 18][..])); + assert_eq!((g.la, g.t_noise, g.freq_res[..3].to_vec()), (Some(3), [0, 14, 18], vec![true, true, false])); + // VARFIX: leading border 1, relative 2, pointer 2: l_A = 1 + let g = grid(&[(VARFIX as u32, 2), (1, 2), (1, 2), (0, 2), (2, 2), (1, 1), (0, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..3], g.la, g.t_noise), (2, &[1, 3, 16][..], Some(1), [1, 3, 16])); + // VARVAR: 1 .. 17 with one border from each end + let g = grid(&[(VARVAR as u32, 2), (1, 2), (1, 2), (1, 2), (1, 2), (0, 2), (0, 2), (0, 2), (0, 1), (0, 1), (0, 1)]).unwrap(); + assert_eq!((g.num_env, &g.t_env[..4], g.la), (3, &[1, 3, 15, 17][..], None)); + } + + /// Grids whose borders do not increase, too many envelopes and a pointer past the envelopes + /// are refused. + #[test] + fn invalid_grids_are_refused() { + // FIXFIX with 8 envelopes + assert!(grid(&[(FIXFIX as u32, 2), (3, 2), (0, 1)]).is_err()); + // VARVAR with 7 envelopes + assert!(grid(&[(VARVAR as u32, 2), (0, 2), (0, 2), (3, 2), (3, 2)]).is_err()); + // FIXVAR whose relative borders run back past the start: 16 - (8 + 8 + 8) + assert!(grid(&[(FIXVAR as u32, 2), (0, 2), (3, 2), (3, 2), (3, 2), (3, 2), (0, 3), (0, 4)]).is_err()); + // VARFIX, 2 envelopes, pointer 3: the middle border would be the trailing one + assert!(grid(&[(VARFIX as u32, 2), (0, 2), (1, 2), (0, 2), (3, 2), (0, 1), (0, 1)]).is_err()); + } +} diff --git a/crates/audio/src/aac/sbr/freq.rs b/crates/audio/src/aac/sbr/freq.rs new file mode 100644 index 000000000..dc2809c10 --- /dev/null +++ b/crates/audio/src/aac/sbr/freq.rs @@ -0,0 +1,353 @@ +//! Frequency band tables (ISO/IEC 14496-3 §4.6.18.3.2) and the patches and limiter bands built on +//! them (§4.6.18.6.3, §4.6.18.7.3). Every table holds QMF channel borders. + +use super::Header; + +/// The most patches the HF generator may build. +const MAX_PATCHES: usize = 5; + +/// The tables of one SBR header at one output rate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FreqTables { + /// First QMF channel of the master table (the start of the SBR range before the crossover). + pub(crate) k0: usize, + /// First QMF channel of the SBR range. + pub(crate) kx: usize, + /// Channels in the SBR range (`kx + m` is its end). + pub(crate) m: usize, + /// High-resolution envelope bands (`n_high + 1` borders). + pub(crate) high: Vec, + /// Low-resolution envelope bands. + pub(crate) low: Vec, + /// Noise floor bands (1 to 5). + pub(crate) noise: Vec, + /// Limiter bands. + pub(crate) lim: Vec, + /// `(first source channel, channels)` of each patch, in order from `kx` up. + pub(crate) patches: Vec<(usize, usize)>, +} + +/// Round half up, as the specification's `NINT`. +fn nint(x: f64) -> i64 { + (x + 0.5).floor() as i64 +} + +/// The start channel offsets of `bs_start_freq`, by output rate (§4.6.18.3.2.1). +fn start_offsets(fs: u32) -> &'static [i8; 16] { + match fs { + 0..=16_000 => &[-8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7], + 16_001..=22_050 => &[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13], + 22_051..=24_000 => &[-5, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16], + 24_001..=32_000 => &[-6, -4, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16], + 32_001..=64_000 => &[-4, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16, 20], + _ => &[-2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16, 20, 24], + } +} + +/// `NINT(f · 128 / fs)` for the 3/4/5 kHz (start) or 6/8/10 kHz (stop) minimum at `fs`. +fn min_channel(fs: u32, low: f64, mid: f64, high: f64) -> i64 { + let f = if fs < 32_000 { + low + } else if fs < 64_000 { + mid + } else { + high + }; + nint(f * 128.0 / f64::from(fs)) +} + +/// The start and stop channels `k0`, `k2` of the master table. +fn start_stop(h: &Header, fs: u32) -> Option<(usize, usize)> { + let k0 = min_channel(fs, 3000.0, 4000.0, 5000.0) + i64::from(start_offsets(fs)[usize::from(h.start_freq & 15)]); + let k2 = match h.stop_freq { + 14 => 2 * k0, + 15 => 3 * k0, + s => { + let stop_min = min_channel(fs, 6000.0, 8000.0, 10_000.0); + if stop_min <= 0 { + return None; + } + let ratio = 64.0 / stop_min as f64; + let at = |p: i64| nint(stop_min as f64 * ratio.powf(p as f64 / 13.0)); + let mut dk: Vec = (0..13).map(|p| at(p + 1) - at(p)).collect(); + dk.sort_unstable(); + stop_min + dk.iter().take(usize::from(s)).sum::() + } + } + .min(64); + // the low band reaches only the analysis bank's 32 channels + (1..32).contains(&k0).then_some(())?; + (k2 > k0).then_some((k0 as usize, k2 as usize)) +} + +/// The master table for `bs_freq_scale` 0: bands of one or two channels. +fn master_linear(k0: usize, k2: usize, alter_scale: bool) -> Option> { + let (dk, bands) = if alter_scale { (2, 2 * nint((k2 - k0) as f64 / 4.0) as usize) } else { (1, 2 * ((k2 - k0) / 2)) }; + if bands == 0 { + return None; + } + let mut v = vec![dk as i64; bands]; + let mut diff = k2 as i64 - (k0 + bands * dk) as i64; + // take the difference from the lowest bands (too wide) or give it to the highest (too narrow) + let mut k = if diff < 0 { 0 } else { bands - 1 }; + while diff != 0 { + let step = if diff < 0 { 1 } else { -1 }; + *v.get_mut(k)? -= step; + diff += step; + k = if step == 1 { k + 1 } else { k.checked_sub(1)? }; + } + cumulative(k0, &v) +} + +/// The borders `start, start + d[0], …`; `None` unless every band is at least a channel wide. +fn cumulative(start: usize, d: &[i64]) -> Option> { + let mut out = Vec::with_capacity(d.len() + 1); + out.push(start); + let mut at = start as i64; + for &d in d { + if d <= 0 { + return None; + } + at += d; + out.push(at as usize); + } + Some(out) +} + +/// The band widths of `2 · NINT(bands · log2(hi / lo) / (2 · warp))` logarithmic bands. +fn log_widths(lo: usize, hi: usize, bands: f64, warp: f64) -> Vec { + let ratio = hi as f64 / lo as f64; + let n = 2 * nint(bands * ratio.log2() / (2.0 * warp)); + let at = |k: i64| nint(lo as f64 * ratio.powf(k as f64 / n as f64)); + let mut d: Vec = (0..n.max(0)).map(|k| at(k + 1) - at(k)).collect(); + d.sort_unstable(); + d +} + +/// The master table for `bs_freq_scale` 1–3: 12, 10 or 8 bands per octave, warped above +/// `2 · k0` by `bs_alter_scale`. +fn master_log(k0: usize, k2: usize, freq_scale: u8, alter_scale: bool) -> Option> { + let bands = [12.0, 10.0, 8.0][usize::from(freq_scale.clamp(1, 3) - 1)]; + let two_regions = k2 as f64 / k0 as f64 > 2.2449; + let k1 = if two_regions { 2 * k0 } else { k2 }; + let d0 = log_widths(k0, k1, bands, 1.0); + let mut table = cumulative(k0, &d0)?; + if table.len() < 2 { + return None; + } + if two_regions { + let mut d1 = log_widths(k1, k2, bands, if alter_scale { 1.3 } else { 1.0 }); + let max0 = d0.iter().copied().max()?; + if d1.first().is_some_and(|&d| d < max0) { + // widen the first upper band to the widest lower one, at the cost of the last + let change = max0 - d1[0]; + d1[0] = max0; + *d1.last_mut()? -= change; + d1.sort_unstable(); + } + table.extend(cumulative(k1, &d1)?.into_iter().skip(1)); + } + Some(table) +} + +impl FreqTables { + /// The tables of `h` at output rate `fs`, or `None` if the header asks for an invalid layout. + pub(crate) fn new(h: &Header, fs: u32) -> Option { + let (k0, k2) = start_stop(h, fs)?; + let master = if h.freq_scale == 0 { master_linear(k0, k2, h.alter_scale)? } else { master_log(k0, k2, h.freq_scale, h.alter_scale)? }; + let n_master = master.len().checked_sub(1)?; + let xover = usize::from(h.xover_band); + if xover >= n_master { + return None; + } + let high: Vec = master.get(xover..)?.to_vec(); + let n_high = high.len() - 1; + let (kx, end) = (high[0], *high.last()?); + if kx > 32 || end > 64 || end <= kx { + return None; + } + let m = end - kx; + // every other border, keeping the first (and the second when the count is odd) + let n_low = n_high - n_high / 2; + let low: Vec = (0..=n_low).map(|k| high[if k == 0 { 0 } else { 2 * k - (n_high & 1) }]).collect(); + let n_q = if h.noise_bands == 0 { 1 } else { (nint(f64::from(h.noise_bands) * (end as f64 / kx as f64).log2()) as usize).max(1) }; + if n_q > 5 || n_q > n_low { + return None; + } + let mut noise = vec![low[0]]; + let mut i = 0; + for k in 1..=n_q { + i += (n_low - i) / (n_q + 1 - k); + noise.push(*low.get(i)?); + } + let patches = patches(&master, k0, kx, m, fs)?; + let lim = limiter(&low, &patches, kx, h.limiter_bands); + Some(FreqTables { k0, kx, m, high, low, noise, lim, patches }) + } + + /// High-resolution envelope bands. + pub(crate) fn n_high(&self) -> usize { + self.high.len() - 1 + } + + /// Noise floor bands. + pub(crate) fn n_q(&self) -> usize { + self.noise.len() - 1 + } + + /// The envelope band table of a resolution (`true`: high). + pub(crate) fn bands(&self, high_res: bool) -> &[usize] { + if high_res { &self.high } else { &self.low } + } +} + +/// Map the low band onto the SBR range (§4.6.18.6.3): runs of source channels, each ending below +/// `k0`, that together cover `kx .. kx + m`. +fn patches(master: &[usize], k0: usize, kx: usize, m: usize, fs: u32) -> Option> { + let n_master = master.len() - 1; + let goal = nint(2.048e6 / f64::from(fs)).max(0) as usize; + let mut k = if goal < kx + m { master.iter().position(|&f| f >= goal).unwrap_or(n_master) } else { n_master }; + let (mut msb, mut usb) = (k0, kx); + let mut out: Vec<(usize, usize)> = Vec::new(); + // each pass either adds a patch or lowers `msb` to `kx`; a stream that does neither twice + // running cannot finish + for _ in 0..2 * (MAX_PATCHES + 1) { + // the highest master border the patch can reach from below k0 + let mut j = k + 1; + let (sb, odd) = loop { + j = j.checked_sub(1)?; + let sb = *master.get(j)?; + let odd = (sb + k0) % 2; + if sb + odd < k0 + msb { + break (sb, odd); + } + }; + let count = sb.saturating_sub(usb); + if count > 0 { + let start = (k0 - odd).checked_sub(count)?; + out.push((start, count)); + if out.len() > MAX_PATCHES { + return None; + } + (usb, msb) = (sb, sb); + } else { + msb = kx; + } + if master.get(k)?.saturating_sub(sb) < 3 { + k = n_master; + } + if sb == kx + m { + if out.len() > 1 && out.last().is_some_and(|p| p.1 < 3) { + out.pop(); + } + return (!out.is_empty()).then_some(out); + } + } + None +} + +/// The limiter band table (§4.6.18.7.3): one band over the range, or about 1.2, 2 or 3 bands per +/// octave on the low-resolution and patch borders. +fn limiter(low: &[usize], patches: &[(usize, usize)], kx: usize, limiter_bands: u8) -> Vec { + let (Some(&first), Some(&last)) = (low.first(), low.last()) else { + return Vec::new(); + }; + if limiter_bands == 0 { + return vec![first, last]; + } + let per_octave = [1.2, 2.0, 3.0][usize::from(limiter_bands.clamp(1, 3) - 1)]; + let mut borders = vec![kx]; + for p in patches { + borders.push(borders.last().copied().unwrap_or(kx) + p.1); + } + let mut t: Vec = low.to_vec(); + t.extend(borders.iter().skip(1).take(patches.len().saturating_sub(1))); + t.sort_unstable(); + let is_border = |v: usize| borders.contains(&v); + let mut k = 1; + while k < t.len() { + let octaves = if t[k - 1] == 0 { f64::INFINITY } else { (t[k] as f64 / t[k - 1] as f64).log2() }; + if octaves * per_octave >= 0.49 { + k += 1; + } else if t[k] == t[k - 1] || !is_border(t[k]) { + t.remove(k); + } else if !is_border(t[k - 1]) { + t.remove(k - 1); + } else { + k += 1; + } + } + if t.len() < 2 { + return vec![first, last]; + } + t +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(start: u8, stop: u8, xover: u8, freq_scale: u8, alter: bool, noise: u8) -> Header { + Header { + amp_res: true, + start_freq: start, + stop_freq: stop, + xover_band: xover, + freq_scale, + alter_scale: alter, + noise_bands: noise, + limiter_bands: 2, + limiter_gains: 2, + interpol_freq: true, + smoothing_mode: true, + } + } + + /// Every header field combination at every output rate gives tables that are strictly + /// increasing, start at `kx`, end at `kx + m`, keep the low band within the analysis bank and + /// patch from below `k0` (or is refused). + #[test] + fn every_header_gives_consistent_tables_or_none() { + let mut valid = 0; + for fs in [16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 64_000, 88_200, 96_000] { + for start in 0..16 { + for stop in 0..16 { + for xover in 0..8 { + for fsc in 0..4 { + for alter in [false, true] { + for noise in 0..4 { + let Some(t) = FreqTables::new(&header(start, stop, xover, fsc, alter, noise), fs) else { continue }; + valid += 1; + for v in [&t.high, &t.low, &t.noise, &t.lim] { + assert!(v.windows(2).all(|w| w[0] < w[1]), "{v:?}"); + assert_eq!(v[0], t.kx); + } + for v in [&t.high, &t.low, &t.noise] { + assert_eq!(*v.last().unwrap(), t.kx + t.m); + } + assert!(t.kx <= 32 && t.kx + t.m <= 64 && t.n_q() <= 5); + // a last patch of under 3 channels is dropped, and the limiter + // bands may then stop short of the range's end + assert!(t.patches.iter().map(|p| p.1).sum::() <= t.m); + assert!(t.lim.len() >= 2 && *t.lim.last().unwrap() <= t.kx + t.m); + assert!(t.patches.iter().all(|&(s, n)| s + n <= t.k0)); + } + } + } + } + } + } + } + assert!(valid > 10_000, "{valid}"); + } + + /// A typical 44.1 kHz header: start channel 12 + offset, stop near 64, three noise bands. + #[test] + fn a_44k_header_has_the_expected_layout() { + let t = FreqTables::new(&header(5, 9, 0, 2, true, 2), 44_100).unwrap(); + // startMin 12 + offset 2 = 14 + assert_eq!((t.k0, t.kx), (14, 14)); + assert_eq!(t.low.len() - 1, t.n_high() - t.n_high() / 2); + assert!(t.n_q() >= 1); + } +} diff --git a/crates/audio/src/aac/sbr/hf.rs b/crates/audio/src/aac/sbr/hf.rs new file mode 100644 index 000000000..43ae572cf --- /dev/null +++ b/crates/audio/src/aac/sbr/hf.rs @@ -0,0 +1,332 @@ +//! HF generation (ISO/IEC 14496-3 §4.6.18.6) and HF adjustment (§4.6.18.7) of one channel. + +use super::bitstream::Grid; +use super::freq::FreqTables; +use super::tables::NOISE; +use super::{Cx, Header, MAX_ENV, T_HF_ADJ, X_SLOTS}; + +/// Low band subband samples of a frame: slots -8 .. 32 relative to the frame (`X_Low`). +pub(crate) type LowBand = [[Cx; 32]; X_SLOTS]; +/// High band subband samples, at slot `l + T_HF_ADJ` for QMF slot `l` of the frame's envelopes. +pub(crate) type HighBand = [[Cx; 64]; X_SLOTS]; + +/// The per-channel state the HF tools carry from frame to frame. +#[derive(Clone, Debug)] +pub(crate) struct History { + /// `bs_invf_mode` and chirp factor per noise band. + invf: [u8; 5], + bw: [f32; 5], + /// The last frame's transient envelope was its last (`l_A = L_E`): no noise in this frame's + /// first. + la_at_end: bool, + /// Sinusoids present in the last envelope, by channel above `kx`. + sine: [bool; 64], + /// Gains and noise levels of the last four slots, newest first (smoothing). + gains: [[f32; 64]; 4], + noise_levels: [[f32; 64]; 4], + /// The smoothing history holds data of the current tables. + primed: bool, + noise_index: usize, + sine_index: usize, +} + +impl History { + pub(crate) fn new() -> History { + History { + invf: [0; 5], + bw: [0.0; 5], + la_at_end: false, + sine: [false; 64], + gains: [[0.0; 64]; 4], + noise_levels: [[0.0; 64]; 4], + primed: false, + noise_index: 0, + sine_index: 0, + } + } + + /// Forget what depends on the frequency tables (after a header that changes them). + pub(crate) fn reset_tables(&mut self) { + self.sine = [false; 64]; + self.primed = false; + self.noise_index = 0; + } +} + +/// The chirp factor of a noise band from its inverse filtering mode and the last frame's. +fn chirp(mode: u8, prev_mode: u8, prev_bw: f32) -> f32 { + let new_bw = match (mode, prev_mode) { + (0, 1) => 0.6, + (0, _) => 0.0, + (1, 0) => 0.6, + (1, _) => 0.75, + (2, _) => 0.9, + _ => 0.98, + }; + let bw = if new_bw < prev_bw { 0.75 * new_bw + 0.25 * prev_bw } else { 0.90625 * new_bw + 0.09375 * prev_bw }; + if bw < 0.015625 { 0.0 } else { bw } +} + +/// Second order linear prediction of one low band channel over the frame and the 6 slots +/// before it (the covariance method, §4.6.18.6.2): `(α0, α1)`. +fn predictor(x: &LowBand, k: usize) -> (Cx, Cx) { + // φ(i, j) = Σ x(n − i + 2) · conj(x(n − j + 2)), n = 0 .. 37 + let phi = |i: usize, j: usize| { + let (mut re, mut im) = (0f64, 0f64); + for n in 0..SLOTS_PLUS_6 { + let (a, b) = (x[n + 2 - i][k], x[n + 2 - j][k]); + re += f64::from(a.re) * f64::from(b.re) + f64::from(a.im) * f64::from(b.im); + im += f64::from(a.im) * f64::from(b.re) - f64::from(a.re) * f64::from(b.im); + } + (re, im) + }; + let (p01, p02, p11, p12, p22) = (phi(0, 1), phi(0, 2), phi(1, 1).0, phi(1, 2), phi(2, 2).0); + let d = p22 * p11 - (p12.0 * p12.0 + p12.1 * p12.1) / (1.0 + 1e-6); + let a1 = if d != 0.0 { + // (φ01 · φ12 − φ02 · φ11) / d + ((p01.0 * p12.0 - p01.1 * p12.1 - p02.0 * p11) / d, (p01.0 * p12.1 + p01.1 * p12.0 - p02.1 * p11) / d) + } else { + (0.0, 0.0) + }; + let a0 = if p11 != 0.0 { + // −(φ01 + α1 · conj(φ12)) / φ11 + (-(p01.0 + a1.0 * p12.0 + a1.1 * p12.1) / p11, -(p01.1 + a1.1 * p12.0 - a1.0 * p12.1) / p11) + } else { + (0.0, 0.0) + }; + let mag = |a: (f64, f64)| (a.0 * a.0 + a.1 * a.1).sqrt(); + if mag(a0) >= 4.0 || mag(a1) >= 4.0 { + return (Cx::default(), Cx::default()); + } + (Cx { re: a0.0 as f32, im: a0.1 as f32 }, Cx { re: a1.0 as f32, im: a1.1 as f32 }) +} + +/// Slots of the covariance sums: the frame's 32 and 6 before it. +const SLOTS_PLUS_6: usize = super::SLOTS + 6; + +/// The index of the band of `bands` holding channel `k` (bands are increasing borders). +fn band_of(bands: &[usize], k: usize) -> usize { + bands.partition_point(|&b| b <= k).saturating_sub(1).min(bands.len().saturating_sub(2)) +} + +/// Patch the low band into the SBR range, inverse filtered (§4.6.18.6.3), for QMF slots +/// `from .. to` of the frame. +pub(crate) fn generate(x: &LowBand, t: &FreqTables, invf: &[u8; 5], h: &mut History, (from, to): (usize, usize), out: &mut HighBand) { + let mut bw = [0f32; 5]; + for i in 0..t.n_q() { + bw[i] = chirp(invf[i], h.invf[i], h.bw[i]); + } + (h.invf, h.bw) = (*invf, bw); + let mut pred: [Option<(Cx, Cx)>; 32] = [None; 32]; + let mut k = t.kx; + for &(start, count) in &t.patches { + for p in start..start + count { + let (a0, a1) = *pred[p].get_or_insert_with(|| predictor(x, p)); + let g = bw[band_of(&t.noise, k)]; + let (c0, c1) = (a0.scale(g), a1.scale(g * g)); + for l in from..to { + let i = l + T_HF_ADJ; + out[i][k] = x[i][p] + c0 * x[i - 1][p] + c1 * x[i - 2][p]; + } + k += 1; + } + } +} + +/// Dequantised envelope and noise floor values of one channel in one frame. +#[derive(Clone, Debug)] +pub(crate) struct Levels { + /// Envelope energy per envelope and band (of that envelope's resolution). + pub(crate) energy: [[f32; 64]; MAX_ENV], + /// Noise to signal ratio per noise floor and noise band. + pub(crate) noise: [[f32; 5]; 2], +} + +/// Gains for one envelope, per channel of the SBR range. +struct EnvelopeGains { + gain: [f32; 64], + noise: [f32; 64], + sine: [f32; 64], +} + +const LIMITER_GAINS: [f32; 4] = [0.70795, 1.0, 1.41254, 1e10]; +/// `h_smooth`, as the specification gives it. +#[allow(clippy::excessive_precision)] +const SMOOTHING: [f32; 5] = [0.33333333333333, 0.30150283239582, 0.21816949906249, 0.11516383427084, 0.03183050093751]; +const EPS: f32 = 1.0; +const EPS0: f32 = 1e-12; + +/// What the adjustment of one channel's frame works from. +pub(crate) struct Frame<'a> { + /// The generated high band. + pub(crate) x: &'a HighBand, + pub(crate) t: &'a FreqTables, + pub(crate) hdr: &'a Header, + pub(crate) g: &'a Grid, + pub(crate) lv: &'a Levels, + /// `bs_add_harmonic`, when the frame codes sinusoids. + pub(crate) harmonic: Option<&'a [bool; 64]>, +} + +/// The gains of envelope `l` (§4.6.18.7.3–4): `delta` is 0 for a transient envelope, which gets +/// no noise. +fn envelope_gains(f: &Frame, l: usize, sine_index: &[bool; 64], delta: bool) -> EnvelopeGains { + let Frame { x, t, hdr, g, lv, .. } = *f; + let (kx, m) = (t.kx, t.m); + let bands = t.bands(g.freq_res[l]); + let (from, to) = (2 * g.t_env[l] + T_HF_ADJ, 2 * g.t_env[l + 1] + T_HF_ADJ); + let slots = (to - from) as f32; + let floor = usize::from(g.num_noise == 2 && g.t_env[l] >= g.t_noise[1]); + // per channel: original energy, current energy, noise ratio, sinusoid in its band + let mut e_orig = [0f32; 64]; + let mut e_curr = [0f32; 64]; + let mut q = [0f32; 64]; + let mut s_band = [false; 64]; + for j in 0..bands.len() - 1 { + let (lo, hi) = (bands[j] - kx, bands[j + 1] - kx); + let any_sine = sine_index[lo..hi].iter().any(|&s| s); + let mut sum = 0f32; + for c in lo..hi { + let ch = c + kx; + let e: f32 = (from..to).map(|i| x[i][ch].norm_sqr()).sum::() / slots; + e_curr[c] = e; + sum += e; + e_orig[c] = lv.energy[l][j]; + q[c] = lv.noise[floor][band_of(&t.noise, ch)]; + s_band[c] = any_sine; + } + if !hdr.interpol_freq { + e_curr[lo..hi].fill(sum / (hi - lo) as f32); + } + } + let mut out = EnvelopeGains { gain: [0.0; 64], noise: [0.0; 64], sine: [0.0; 64] }; + for c in 0..m { + let (eo, ec, q) = (e_orig[c], e_curr[c], q[c]); + out.noise[c] = (eo * q / (1.0 + q)).sqrt(); + if sine_index[c] { + out.sine[c] = (eo / (1.0 + q)).sqrt(); + } + out.gain[c] = + if s_band[c] { (eo / (EPS + ec) * q / (1.0 + q)).sqrt() } else { (eo / ((EPS + ec) * (1.0 + if delta { q } else { 0.0 }))).sqrt() }; + } + // limit per limiter band, then boost back the energy the limiter took + let lim_gain = LIMITER_GAINS[usize::from(hdr.limiter_gains & 3)]; + for w in t.lim.windows(2) { + let (lo, hi) = (w[0] - kx, w[1] - kx); + let (so, sc) = (e_orig[lo..hi].iter().sum::(), e_curr[lo..hi].iter().sum::()); + let max = (lim_gain * ((EPS0 + so) / (EPS0 + sc)).sqrt()).min(1e5); + let mut den = 0f32; + for c in lo..hi { + if out.gain[c] > max { + out.noise[c] *= max / out.gain[c]; + out.gain[c] = max; + } + den += e_curr[c] * out.gain[c] * out.gain[c]; + if out.sine[c] != 0.0 { + den += out.sine[c] * out.sine[c]; + } else if delta { + den += out.noise[c] * out.noise[c]; + } + } + let boost = ((EPS0 + so) / (EPS0 + den)).sqrt().min(1.584_893_2); + for c in lo..hi { + out.gain[c] *= boost; + out.noise[c] *= boost; + out.sine[c] *= boost; + } + } + out +} + +/// Adjust the generated high band to the transmitted envelopes and add noise and sinusoids +/// (§4.6.18.7), for the frame's envelopes, into `y`. +pub(crate) fn adjust(f: &Frame, h: &mut History, y: &mut HighBand) { + let Frame { x, t, hdr, g, harmonic, .. } = *f; + let (kx, m) = (t.kx, t.m); + let smooth = !hdr.smoothing_mode; + let prev_la_at_end = h.la_at_end; + for l in 0..g.num_env { + // sinusoids start at the transient envelope, unless one was already playing in the band + let mut sine_index = [false; 64]; + if let Some(harm) = harmonic { + for i in 0..t.n_high() { + let c = (t.high[i] + t.high[i + 1]) / 2 - kx; + sine_index[c] = harm[i] && (g.la.is_none_or(|la| l >= la) || h.sine[c]); + } + } + let transient = g.la == Some(l) || (l == 0 && prev_la_at_end); + let e = envelope_gains(f, l, &sine_index, !transient); + if !h.primed { + h.gains = [e.gain; 4]; + h.noise_levels = [e.noise; 4]; + h.primed = true; + } + for i in 2 * g.t_env[l]..2 * g.t_env[l + 1] { + let (mut gain, mut noise) = (e.gain, e.noise); + if smooth && !transient { + for c in 0..m { + gain[c] = SMOOTHING[0] * e.gain[c] + (0..4).map(|j| SMOOTHING[j + 1] * h.gains[j][c]).sum::(); + noise[c] = SMOOTHING[0] * e.noise[c] + (0..4).map(|j| SMOOTHING[j + 1] * h.noise_levels[j][c]).sum::(); + } + } + h.gains.rotate_right(1); + h.noise_levels.rotate_right(1); + (h.gains[0], h.noise_levels[0]) = (e.gain, e.noise); + let (sin_re, sin_im) = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)][h.sine_index]; + let row = &mut y[i + T_HF_ADJ]; + for c in 0..m { + let ch = c + kx; + let mut v = x[i + T_HF_ADJ][ch].scale(gain[c]); + h.noise_index = (h.noise_index + 1) & 511; + if e.sine[c] != 0.0 { + let sign = if ch % 2 == 0 { 1.0 } else { -1.0 }; + v.re += e.sine[c] * sin_re; + v.im += e.sine[c] * sin_im * sign; + } else if !transient { + let [nr, ni] = NOISE[h.noise_index]; + v.re += noise[c] * nr; + v.im += noise[c] * ni; + } + row[ch] = v; + } + h.sine_index = (h.sine_index + 1) & 3; + } + if l + 1 == g.num_env { + h.sine = sine_index; + } + } + h.la_at_end = g.la == Some(g.num_env); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The chirp factor follows §4.6.18.6.2: rising quickly, falling slowly, and zero below 1/64. + #[test] + fn chirp_factors() { + assert!((chirp(3, 3, 0.0) - 0.90625 * 0.98).abs() < 1e-6); + assert!((chirp(0, 3, 0.98) - 0.25 * 0.98).abs() < 1e-6); + assert_eq!(chirp(0, 0, 0.05), 0.0); + assert!((chirp(1, 0, 0.0) - 0.90625 * 0.6).abs() < 1e-6); + } + + /// A channel that is a decaying complex exponential is predicted exactly by its first order + /// term. + #[test] + fn prediction_of_a_resonance() { + let mut x: LowBand = [[Cx::default(); 32]; X_SLOTS]; + let r = Cx { re: 0.9 * 0.3f32.cos(), im: 0.9 * 0.3f32.sin() }; + let mut v = Cx { re: 1.0, im: 0.0 }; + for slot in x.iter_mut() { + slot[3] = v; + v = v * r; + } + let (a0, a1) = predictor(&x, 3); + // x(n) + α0·x(n−1) + α1·x(n−2) = 0 with α0 = −r, α1 = 0 + assert!((a0.re + r.re).abs() < 1e-3 && (a0.im + r.im).abs() < 1e-3, "{a0:?}"); + assert!(a1.re.abs() < 1e-3 && a1.im.abs() < 1e-3, "{a1:?}"); + // a silent channel has no predictor + assert_eq!(predictor(&x, 4), (Cx::default(), Cx::default())); + } +} diff --git a/crates/audio/src/aac/sbr/mod.rs b/crates/audio/src/aac/sbr/mod.rs new file mode 100644 index 000000000..32a6c55b0 --- /dev/null +++ b/crates/audio/src/aac/sbr/mod.rs @@ -0,0 +1,513 @@ +//! Spectral band replication (HE-AAC, ISO/IEC 14496-3 §4.6.18): rebuilds the band above the +//! AAC-LC core from the core's own low band and the envelope data of the stream's SBR fill +//! elements, at twice the core rate. +//! +//! Each channel's core output goes through a 32-band QMF analysis bank. The HF generator copies +//! (patches) low band channels into the SBR range, inverse filtered; the HF adjuster scales them +//! to the transmitted envelopes and adds noise and sinusoids; a 64-band QMF synthesis bank merges +//! the delayed low band and the new high band. A frame without usable SBR data (none sent yet, or +//! damaged) still passes through the banks, so the output keeps its rate and delay. +//! +//! Written from the SBR description and tables of ETSI ES 201 980 (DRM, Annex I has the tables it +//! shares with ISO/IEC 14496-3) and the decoder process of ISO/IEC 14496-3. Parametric stereo +//! (HE-AAC v2) is not decoded: its extension is skipped and the mono core is extended alone. + +mod bitstream; +mod freq; +mod hf; +mod qmf; +mod tables; + +use std::ops::{Add, Mul}; + +use craft_media::bitstream::BitReader; + +use self::bitstream::{ChannelData, Frame}; +use self::freq::FreqTables; +use self::hf::{HighBand, History, Levels, LowBand}; +use self::qmf::{Analysis, Synthesis}; + +/// QMF slots per frame: 1024 core samples in slots of 32. +pub(crate) const SLOTS: usize = 32; +/// The most envelopes in a frame. +pub(crate) const MAX_ENV: usize = 5; +/// Slots of the last frame kept ahead of this one's low band (`t_HFGen`). +const T_HF_GEN: usize = 8; +/// The SBR frame lags the core frame by `T_HF_GEN − T_HF_ADJ` slots. +pub(crate) const T_HF_ADJ: usize = 2; +/// Slots in the low and high band buffers: the frame and `T_HF_GEN` before it. +pub(crate) const X_SLOTS: usize = SLOTS + T_HF_GEN; +/// The 16-bit full scale the specification's energies are in. +const PCM_SCALE: f32 = 32768.0; +/// `NOISE_FLOOR_OFFSET`. +const NOISE_OFFSET: i32 = 6; + +/// Output samples by which the SBR decoder delays the core: the filter banks, and the SBR frame +/// lagging the core frame by `T_HF_GEN − T_HF_ADJ` slots of 64. +pub const DELAY: usize = qmf::DELAY + 64 * (T_HF_GEN - T_HF_ADJ); + +/// A complex subband sample. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub(crate) struct Cx { + pub(crate) re: f32, + pub(crate) im: f32, +} + +impl Cx { + fn scale(self, g: f32) -> Cx { + Cx { re: self.re * g, im: self.im * g } + } + fn norm_sqr(self) -> f32 { + self.re * self.re + self.im * self.im + } +} + +impl Add for Cx { + type Output = Cx; + fn add(self, o: Cx) -> Cx { + Cx { re: self.re + o.re, im: self.im + o.im } + } +} + +impl Mul for Cx { + type Output = Cx; + fn mul(self, o: Cx) -> Cx { + Cx { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re } + } +} + +/// `sbr_header()` (§4.5.2.8.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Header { + pub(crate) amp_res: bool, + pub(crate) start_freq: u8, + pub(crate) stop_freq: u8, + pub(crate) xover_band: u8, + pub(crate) freq_scale: u8, + pub(crate) alter_scale: bool, + pub(crate) noise_bands: u8, + pub(crate) limiter_bands: u8, + pub(crate) limiter_gains: u8, + pub(crate) interpol_freq: bool, + pub(crate) smoothing_mode: bool, +} + +impl Header { + /// Whether `o` keeps the frequency band tables (a change of any of these fields is an SBR + /// reset). + fn same_tables(&self, o: &Header) -> bool { + (self.start_freq, self.stop_freq, self.freq_scale, self.alter_scale, self.xover_band, self.noise_bands) + == (o.start_freq, o.stop_freq, o.freq_scale, o.alter_scale, o.xover_band, o.noise_bands) + } +} + +/// Where an element's SBR data is: the access unit, and the bit range of the fill element's +/// payload after its extension type. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Payload<'a> { + pub(crate) data: &'a [u8], + pub(crate) start: usize, + pub(crate) end: usize, + /// `EXT_SBR_DATA_CRC`: a 10-bit CRC leads the data. + pub(crate) crc: bool, +} + +/// Quantised envelope values per envelope and band, and noise floor values per floor and band. +type Quantised = ([[i32; 64]; MAX_ENV], [[i32; 5]; 2]); + +/// One channel's state across frames. +#[derive(Clone)] +struct Channel { + analysis: Analysis, + synthesis: Synthesis, + /// Low band of the frame, with the last `T_HF_GEN` slots of the frame before ahead of it. + x_low: Box, + /// Adjusted high band of the last frame (its envelopes may reach into this one). + y_prev: Box, + /// Where `y_prev` holds data: QMF channels, and the end of its slots. + y_prev_range: (usize, usize), + y_prev_end: usize, + /// Quantised envelope values of the last envelope, and its resolution; noise floor values of + /// the last noise floor (time-direction delta coding). + env: [i32; 64], + env_high_res: bool, + noise: [i32; 5], + history: History, +} + +impl Channel { + fn new() -> Channel { + Channel { + analysis: Analysis::new(), + synthesis: Synthesis::new(), + x_low: Box::new([[Cx::default(); 32]; X_SLOTS]), + y_prev: Box::new([[Cx::default(); 64]; X_SLOTS]), + y_prev_range: (0, 0), + y_prev_end: 0, + env: [0; 64], + env_high_res: false, + noise: [0; 5], + history: History::new(), + } + } + + /// Decode the delta-coded envelope and noise floor values (§4.6.18.3.4); `scale` is 2 for a + /// balance channel. + fn absolute(&mut self, d: &ChannelData, t: &FreqTables, scale: i32) -> Quantised { + let mut env = [[0i32; 64]; MAX_ENV]; + for l in 0..d.grid.num_env { + let res = d.grid.freq_res[l]; + let n = t.bands(res).len() - 1; + if d.df_env[l] { + for k in 0..n { + // the value this band refers to in the last envelope, mapped across resolutions + let reference = if res == self.env_high_res { + k + } else if res { + hf_band_of(t.bands(false), t.high[k]) + } else { + t.high.iter().position(|&b| b == t.low[k]).unwrap_or(0) + }; + env[l][k] = self.env.get(reference).copied().unwrap_or(0) + scale * d.env[l][k]; + } + } else { + env[l][0] = scale * d.env[l][0]; + for k in 1..n { + env[l][k] = env[l][k - 1] + scale * d.env[l][k]; + } + } + (self.env, self.env_high_res) = (env[l], res); + } + let mut noise = [[0i32; 5]; 2]; + for l in 0..d.grid.num_noise { + for k in 0..t.n_q() { + let delta = scale * d.noise[l][k]; + noise[l][k] = if d.df_noise[l] { + self.noise[k] + delta + } else if k == 0 { + delta + } else { + noise[l][k - 1] + delta + }; + } + self.noise = noise[l]; + } + (env, noise) + } +} + +/// The index of the band of `bands` holding channel `k`. +fn hf_band_of(bands: &[usize], k: usize) -> usize { + bands.partition_point(|&b| b <= k).saturating_sub(1) +} + +/// `2^x`, kept to a range whose energies stay finite. +fn exp2(x: f32) -> f32 { + x.clamp(-64.0, 64.0).exp2() +} + +/// Dequantise one channel (§4.6.18.3.5), or a coupled pair from its level and balance channels. +fn dequantise(frame: &Frame, values: &[Quantised], t: &FreqTables) -> Vec { + let g = &frame.channels[0].grid; + let amp = if g.amp_res { 1.0 } else { 0.5 }; + let mut out = vec![Levels { energy: [[0.0; 64]; MAX_ENV], noise: [[0.0; 5]; 2] }; values.len()]; + if frame.coupling && values.len() == 2 { + let pan = if g.amp_res { 12.0 } else { 24.0 }; + let ((e0, q0), (e1, q1)) = (&values[0], &values[1]); + for l in 0..g.num_env { + for k in 0..t.bands(g.freq_res[l]).len() - 1 { + let level = 64.0 * exp2(amp * e0[l][k] as f32 + 1.0); + let balance = amp * (pan - e1[l][k] as f32); + out[0].energy[l][k] = level / (1.0 + exp2(balance)); + out[1].energy[l][k] = level / (1.0 + exp2(-balance)); + } + } + for l in 0..g.num_noise { + for k in 0..t.n_q() { + let level = exp2((NOISE_OFFSET - q0[l][k] + 1) as f32); + let balance = (12 - q1[l][k]) as f32; + out[0].noise[l][k] = level / (1.0 + exp2(balance)); + out[1].noise[l][k] = level / (1.0 + exp2(-balance)); + } + } + return out; + } + for ((lv, (e, q)), d) in out.iter_mut().zip(values).zip(&frame.channels) { + let g = &d.grid; + let amp = if g.amp_res { 1.0 } else { 0.5 }; + for l in 0..g.num_env { + for k in 0..t.bands(g.freq_res[l]).len() - 1 { + lv.energy[l][k] = 64.0 * exp2(amp * e[l][k] as f32); + } + } + for l in 0..g.num_noise { + for k in 0..t.n_q() { + lv.noise[l][k] = exp2((NOISE_OFFSET - q[l][k]) as f32); + } + } + } + out +} + +/// The SBR decoder of one channel element: a single channel (SCE, or an LFE, which has no SBR +/// data) or a channel pair (CPE). +#[derive(Clone)] +pub(crate) struct Element { + /// Output sampling rate. + rate: u32, + header: Option
, + tables: Option, + channels: Vec, +} + +impl Element { + pub(crate) fn new(channels: usize, rate: u32) -> Element { + Element { rate, header: None, tables: None, channels: (0..channels).map(|_| Channel::new()).collect() } + } + + pub(crate) fn channels(&self) -> usize { + self.channels.len() + } + + /// Forget the signal (a seek). The header is kept: it holds until the stream sends another. + pub(crate) fn reset(&mut self) { + for c in &mut self.channels { + *c = Channel::new(); + } + } + + /// Read an element's SBR data: `None` without a header to read it by. + fn read(&mut self, p: Payload) -> Option { + let mut br = BitReader::new(p.data); + br.seek_bits(p.start); + if p.crc { + br.skip(10).ok()?; + } + if br.read_flag().ok()? { + let h = Header::read(&mut br).ok()?; + if self.header != Some(h) { + if self.header.is_none_or(|o| !o.same_tables(&h)) { + for c in &mut self.channels { + c.history.reset_tables(); + } + } + self.tables = FreqTables::new(&h, self.rate); + self.header = Some(h); + } + } + let (h, t) = (self.header?, self.tables.as_ref()?); + let frame = bitstream::read_data(&mut br, t, h.amp_res, self.channels.len()).ok()?; + (br.position() <= p.end).then_some(frame) + } + + /// Decode one frame: 1024 core samples per channel in, 2048 out. + pub(crate) fn process(&mut self, core: &[Vec], payload: Option) -> Vec> { + for (c, pcm) in self.channels.iter_mut().zip(core) { + c.x_low.copy_within(SLOTS..X_SLOTS, 0); + let scaled: Vec = pcm.iter().map(|s| s * PCM_SCALE).collect(); + c.analysis.process(&scaled, &mut c.x_low[T_HF_GEN..]); + } + let frame = payload.and_then(|p| self.read(p)); + let mut y: Vec> = self.channels.iter().map(|_| Box::new([[Cx::default(); 64]; X_SLOTS])).collect(); + // per channel: kx, m, and the first and end slot of `y` this frame fills + let mut current: Vec> = vec![None; self.channels.len()]; + if let (Some(frame), Some(h), Some(t)) = (&frame, self.header, self.tables.as_ref()) { + let values: Vec<_> = self + .channels + .iter_mut() + .zip(&frame.channels) + .enumerate() + .map(|(i, (c, d))| c.absolute(d, t, if frame.coupling && i == 1 { 2 } else { 1 })) + .collect(); + let levels = dequantise(frame, &values, t); + for (((c, d), (lv, y)), cur) in self.channels.iter_mut().zip(&frame.channels).zip(levels.iter().zip(&mut y)).zip(&mut current) { + let g = &d.grid; + let slots = (2 * g.t_env[0], 2 * g.t_env[g.num_env]); + let mut x_high: Box = Box::new([[Cx::default(); 64]; X_SLOTS]); + hf::generate(&c.x_low, t, &d.invf, &mut c.history, slots, &mut x_high); + let f = hf::Frame { x: &x_high, t, hdr: &h, g, lv, harmonic: d.harmonic.as_ref() }; + hf::adjust(&f, &mut c.history, y); + *cur = Some((t.kx, t.m, slots.0 + T_HF_ADJ, slots.1 + T_HF_ADJ)); + } + } + let mut out = vec![vec![0f32; 2 * 1024]; self.channels.len()]; + for (((c, y), out), current) in self.channels.iter_mut().zip(y).zip(&mut out).zip(current) { + for (l, block) in out.as_chunks_mut::<64>().0.iter_mut().enumerate() { + let i = l + T_HF_ADJ; + let mut x = [Cx::default(); 64]; + // the high band of this slot: this frame's envelopes, the last frame's reaching + // into this one, or none + let high = match current { + Some((kx, m, from, to)) if (from..to).contains(&i) => Some((&y[i], kx, m)), + _ if i + SLOTS < c.y_prev_end => Some((&c.y_prev[i + SLOTS], c.y_prev_range.0, c.y_prev_range.1)), + _ => None, + }; + let low = high.map_or(current.map_or(32, |cur| cur.0), |h| h.1); + x[..low].copy_from_slice(&c.x_low[i][..low]); + if let Some((row, kx, m)) = high { + x[kx..kx + m].copy_from_slice(&row[kx..kx + m]); + } + c.synthesis.process(&x, 64, block); + } + for s in out.iter_mut() { + *s /= PCM_SCALE; + } + c.y_prev = y; + (c.y_prev_range, c.y_prev_end) = current.map_or(((0, 0), 0), |cur| ((cur.0, cur.1), cur.3)); + } + out + } +} + +/// The header of [`test_payload`]. +#[cfg(test)] +pub(crate) const TEST_HEADER: Header = Header { + amp_res: true, + start_freq: 5, + stop_freq: 9, + xover_band: 0, + freq_scale: 2, + alter_scale: true, + noise_bands: 2, + limiter_bands: 2, + limiter_gains: 2, + interpol_freq: true, + smoothing_mode: true, +}; + +/// An SBR single channel element for tests at output rate `rate`: [`TEST_HEADER`] (3 dB steps, +/// start 5, stop 9, the default extra fields), then one FIXFIX envelope at high resolution with +/// band `k` at `env(k)` (1.5 dB steps, as a single FIXFIX envelope has) and every noise floor +/// value `noise`. +#[cfg(test)] +pub(crate) fn test_payload(rate: u32, env: impl Fn(usize) -> i32, noise: u32) -> Option> { + use craft_media::bitstream::BitWriter; + let t = FreqTables::new(&TEST_HEADER, rate)?; + let (env, n_q): (Vec, usize) = ((0..t.n_high()).map(env).collect(), t.n_q()); + let mut bw = BitWriter::new(); + bw.write_bits(1, 1); // bs_header_flag + for (v, n) in [(1, 1), (5, 4), (9, 4), (0, 3), (0, 2), (0, 1), (0, 1)] { + bw.write_bits(v, n); // amp_res, start, stop, xover, reserved, no extra fields + } + bw.write_bits(0, 1); // bs_data_extra + bw.write_bits(u32::from(bitstream::FIXFIX), 2); + bw.write_bits(0, 2); // one envelope + bw.write_bits(1, 1); // high resolution + bw.write_bits(0, 2); // frequency-direction envelope and noise floor + for _ in 0..n_q { + bw.write_bits(0, 2); // no inverse filtering + } + bw.write_bits(env[0] as u32, 7); + for w in env.windows(2) { + let (code, len) = tables::F_ENV_1_5DB[(w[1] - w[0] + 60) as usize]; + bw.write_bits(code, u32::from(len)); + } + bw.write_bits(noise, 5); + for _ in 1..n_q { + let (code, len) = tables::F_ENV_3_0DB[31]; + bw.write_bits(code, u32::from(len)); + } + bw.write_bits(0, 2); // no sinusoids, no extended data + Some(bw.finish()) +} + +#[cfg(test)] +mod tests { + use std::f64::consts::PI; + + use super::*; + + const RATE: u32 = 44_100; + + fn tables() -> FreqTables { + FreqTables::new(&TEST_HEADER, RATE).unwrap() + } + + /// Mean energy per DFT bin of `x` between `lo` and `hi` QMF channels (64 per half rate). + fn energy(x: &[f32], lo: usize, hi: usize) -> f64 { + let n = x.len(); + let (b0, b1) = (lo * n / 128, hi * n / 128); + let mut e = 0f64; + for b in b0 + 2..b1 - 2 { + let (mut re, mut im) = (0f64, 0f64); + for (i, &s) in x.iter().enumerate() { + let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos(); + let ph = 2.0 * PI * (b * i) as f64 / n as f64; + re += f64::from(s) * w * ph.cos(); + im += f64::from(s) * w * ph.sin(); + } + e += re * re + im * im; + } + e / (b1 - b0 - 4) as f64 + } + + fn run(e: &mut Element, data: &[u8], frames: usize) -> Vec { + let core = vec![vec![0f32; 1024]]; + let mut out = Vec::new(); + for _ in 0..frames { + let p = Payload { data, start: 0, end: 8 * data.len(), crc: false }; + let block = e.process(&core, Some(p)); + assert_eq!((block.len(), block[0].len()), (1, 2048)); + assert!(block[0].iter().all(|s| s.is_finite())); + out.extend_from_slice(&block[0]); + } + out + } + + /// Over a silent core the high band is the noise the envelope asks for: the upper bands, + /// 12 steps of 1.5 dB above the lower ones, come out 18 dB louder, and nothing below `kx`. + #[test] + fn the_high_band_follows_the_envelope() { + let t = tables(); + let mid = t.n_high() / 2; + let data = test_payload(RATE, |k| if k < mid { 30 } else { 42 }, 0).unwrap(); + let mut e = Element::new(1, RATE); + let out = run(&mut e, &data, 6); + let tail = &out[out.len() - 4096..]; + let (lower, upper) = (energy(tail, t.high[0], t.high[mid]), energy(tail, t.high[mid], t.high[t.n_high()])); + let step = 10.0 * (upper / lower).log10(); + assert!((step - 18.06).abs() < 1.5, "{step:.2} dB"); + let below = energy(tail, 1, t.kx - 1); + assert!(10.0 * (lower / below.max(1e-30)).log10() > 60.0); + // the same envelope without its header is not read: an element that has seen none + // passes the core through alone + let mut fresh = Element::new(1, RATE); + let mut no_header = data.clone(); + no_header[0] &= 0x7F; + assert!(run(&mut fresh, &no_header, 3).iter().all(|&s| s == 0.0)); + } + + /// Damaged SBR data (flipped bits, cut short, or noise) never panics and keeps the output + /// finite and of full length. + #[test] + fn damaged_sbr_data_is_survived() { + let good = test_payload(RATE, |k| 20 + k as i32, 3).unwrap(); + let mut seed = 0x1234_5678u32; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + let mut e = Element::new(1, RATE); + let mut pair = Element::new(2, RATE); + for i in 0..600 { + let mut data = good.clone(); + for _ in 0..1 + next() % 6 { + let bit = next() as usize % (8 * data.len()); + data[bit / 8] ^= 0x80 >> (bit % 8); + } + if i % 3 == 0 { + data.truncate(1 + next() as usize % data.len()); + } + if i % 5 == 0 { + data = (0..1 + next() % 40).map(|_| next() as u8).collect(); + data[0] |= 0x80; + } + run(&mut e, &data, 1); + let p = Payload { data: &data, start: 0, end: 8 * data.len(), crc: i % 2 == 0 }; + let out = pair.process(&[vec![0.1; 1024], vec![-0.1; 1024]], Some(p)); + assert!(out.iter().all(|c| c.len() == 2048 && c.iter().all(|s| s.is_finite()))); + } + } +} diff --git a/crates/audio/src/aac/sbr/qmf.rs b/crates/audio/src/aac/sbr/qmf.rs new file mode 100644 index 000000000..de947f303 --- /dev/null +++ b/crates/audio/src/aac/sbr/qmf.rs @@ -0,0 +1,155 @@ +//! The SBR filter banks (ISO/IEC 14496-3 §4.6.18.4): a 32-band complex QMF analysis bank at the +//! core rate and a 64-band complex QMF synthesis bank at the output rate, sharing the window `c`. +//! +//! Each bank is a polyphase window followed by a complex modulation: analysis +//! `2·exp(iπ(k + ½)(2n − ½)/64)`, synthesis `exp(iπ(k + ½)(2n − 251)/128)/64`. With the window's +//! indexing, the two phase offsets put the banks' combined response on its centre tap, so the low +//! band comes back unchanged (to about -60 dB, the window's own limit), 576 output samples late. +//! The synthesis bank holds back its last two output samples, which makes it 578: with the SBR +//! frame's lag, the 962 samples by which HE-AAC decoders (ffmpeg's among them) delay the core, and +//! which the priming encoders signal counts on. + +use std::f64::consts::PI; +use std::sync::OnceLock; + +use super::Cx; +use super::tables::QMF_WINDOW; + +/// Output samples by which analysis followed by synthesis delays the low band. +pub(crate) const DELAY: usize = 578; + +/// Modulation matrices, computed once. +struct Kernels { + /// Analysis, at `[k][n]`: 32 × 64. + analysis: Vec<[Cx; 64]>, + /// Synthesis, at `[n][k]`: 128 × 64. + synthesis: Vec<[Cx; 64]>, +} + +fn kernels() -> &'static Kernels { + static K: OnceLock = OnceLock::new(); + K.get_or_init(|| { + let cx = |phase: f64, scale: f64| Cx { re: (phase.cos() * scale) as f32, im: (phase.sin() * scale) as f32 }; + let analysis = (0..32).map(|k| std::array::from_fn(|n| cx(PI * (k as f64 + 0.5) * (2.0 * n as f64 - 0.5) / 64.0, 2.0))).collect(); + let synthesis = (0..128).map(|n| std::array::from_fn(|k| cx(PI * (k as f64 + 0.5) * (2.0 * n as f64 - 251.0) / 128.0, 1.0 / 64.0))).collect(); + Kernels { analysis, synthesis } + }) +} + +/// The 32-band analysis bank of one channel. +#[derive(Clone)] +pub(crate) struct Analysis { + /// The last 320 input samples, newest first. + x: Box<[f32; 320]>, +} + +impl Analysis { + pub(crate) fn new() -> Analysis { + Analysis { x: Box::new([0.0; 320]) } + } + + /// Split `32 · out.len()` input samples into `out.len()` slots of 32 subbands. + pub(crate) fn process(&mut self, input: &[f32], out: &mut [[Cx; 32]]) { + let k = kernels(); + for (block, slot) in input.as_chunks::<32>().0.iter().zip(out.iter_mut()) { + self.x.copy_within(0..288, 32); + for (n, &s) in block.iter().enumerate() { + self.x[31 - n] = s; + } + let mut u = [0f32; 64]; + for (n, u) in u.iter_mut().enumerate() { + *u = (0..5).map(|j| self.x[n + 64 * j] * QMF_WINDOW[2 * (n + 64 * j)]).sum(); + } + for (sb, row) in slot.iter_mut().zip(&k.analysis) { + let (mut re, mut im) = (0f32, 0f32); + for (u, m) in u.iter().zip(row) { + re += u * m.re; + im += u * m.im; + } + *sb = Cx { re, im }; + } + } + } +} + +/// The 64-band synthesis bank of one channel. +#[derive(Clone)] +pub(crate) struct Synthesis { + v: Box<[f32; 1280]>, + /// The last two output samples, which the next slot puts out first. + held: [f32; 2], +} + +impl Synthesis { + pub(crate) fn new() -> Synthesis { + Synthesis { v: Box::new([0.0; 1280]), held: [0.0; 2] } + } + + /// Merge one slot of 64 subbands into 64 output samples (`out[..64]`, of which it needs 64). + /// Bands from `bands` up are taken as zero. + pub(crate) fn process(&mut self, x: &[Cx; 64], bands: usize, out: &mut [f32]) { + let k = kernels(); + let bands = bands.min(64); + self.v.copy_within(0..1152, 128); + for (n, row) in k.synthesis.iter().enumerate() { + self.v[n] = x[..bands].iter().zip(row).map(|(x, m)| x.re * m.re - x.im * m.im).sum(); + } + let mut slot = [0f32; 66]; + slot[..2].copy_from_slice(&self.held); + for (n, o) in slot[2..].iter_mut().enumerate() { + let mut s = 0f32; + for i in 0..5 { + s += self.v[256 * i + n] * QMF_WINDOW[128 * i + n]; + s += self.v[256 * i + 192 + n] * QMF_WINDOW[128 * i + 64 + n]; + } + *o = s; + } + self.held = [slot[64], slot[65]]; + for (o, s) in out.iter_mut().zip(&slot[..64]) { + *o = *s; + } + } +} + +#[cfg(test)] +mod tests { + use super::super::SLOTS; + use super::*; + + fn tones(t: f64) -> f64 { + [0.0371, 0.0913, 0.1527, 0.2211, 0.2903, 0.3519, 0.4127].iter().enumerate().map(|(j, f)| (2.0 * PI * f * t + j as f64).sin() * 0.1).sum() + } + + /// Tones across the core band through analysis and synthesis (low 32 bands only) come out at + /// twice the rate, [`DELAY`] samples late, with the error 60 dB down. + #[test] + fn analysis_then_synthesis_reconstructs_the_input() { + let input: Vec = (0..8 * 1024).map(|i| tones(i as f64) as f32).collect(); + let (mut a, mut s) = (Analysis::new(), Synthesis::new()); + let mut out = Vec::new(); + let mut slots = [[Cx::default(); 32]; SLOTS]; + for frame in input.as_chunks::<1024>().0 { + a.process(frame, &mut slots); + for slot in &slots { + let mut x = [Cx::default(); 64]; + x[..32].copy_from_slice(slot); + let mut o = [0f32; 64]; + s.process(&x, 32, &mut o); + out.extend_from_slice(&o); + } + } + assert_eq!(out.len(), 2 * input.len()); + let snr_at = |delay: usize| { + let (mut err, mut sig) = (0f64, 0f64); + for (i, &o) in out.iter().enumerate().skip(3000) { + let want = tones((i as f64 - delay as f64) / 2.0); + err += (o as f64 - want).powi(2); + sig += want * want; + } + 10.0 * (sig / err).log10() + }; + let snr = snr_at(DELAY); + assert!(snr > 60.0, "reconstruction SNR {snr:.1} dB"); + assert!(snr_at(DELAY - 1) < 40.0 && snr_at(DELAY + 1) < 40.0); + } +} diff --git a/crates/audio/src/aac/sbr/tables.rs b/crates/audio/src/aac/sbr/tables.rs new file mode 100644 index 000000000..7a648120e --- /dev/null +++ b/crates/audio/src/aac/sbr/tables.rs @@ -0,0 +1,357 @@ +//! SBR tables (ETSI ES 201 980 V1.2.2 Annex I, the SBR tables it shares with ISO/IEC 14496-3 §4.A.6). +//! +//! Transcribed from the specification's tables, which give the window to 14 digits (more than an +//! `f32` holds). Annex I misprints the labels of `c[297]` and `c[305]`; the values are in order. +//! Each Huffman table is a complete prefix code (see the tests in `sbr::bitstream`). + +#![allow(clippy::excessive_precision)] + +/// The QMF bank window `c` (640 coefficients). +#[rustfmt::skip] +pub(crate) static QMF_WINDOW: [f32; 640] = [ + -0.00055252865047, -0.00056176925738, -0.00049475180896, -0.00048752279712, -0.00048937912498, -0.00050407143497, -0.00052265642972, -0.00054665656337, + -0.00056778025613, -0.00058709304852, -0.00061327473938, -0.00063124935319, -0.00065403333621, -0.00067776907764, -0.00069416146273, -0.00071577364744, + -0.00072550431222, -0.00074409418541, -0.00074905980532, -0.00076813719270, -0.00077248485949, -0.00078343322877, -0.00077798694927, -0.00078036647100, + -0.00078014496257, -0.00077579773310, -0.00076307935757, -0.00075300014201, -0.00073193571525, -0.00072153919876, -0.00069179375372, -0.00066504150893, + -0.00063415949025, -0.00059461189330, -0.00055645763906, -0.00051455722108, -0.00046063254803, -0.00040951214522, -0.00035011758756, -0.00028969811748, + -0.00020983373440, -0.00014463809349, -0.00006173344072, 0.00001349497418, 0.00010943831274, 0.00020430170688, 0.00029495311041, 0.00040265402160, + 0.00051073884952, 0.00062393761391, 0.00074580258865, 0.00086084433262, 0.00098859883015, 0.00112501551307, 0.00125778846475, 0.00139024948272, + 0.00154432198471, 0.00168680832531, 0.00183482654224, 0.00198411407369, 0.00214615835557, 0.00230172547746, 0.00246256169126, 0.00262017586902, + 0.00278704643465, 0.00294694477165, 0.00311254206525, 0.00327396134847, 0.00344188741828, 0.00360082681231, 0.00376039229104, 0.00392074323703, + 0.00408197531935, 0.00422642692270, 0.00437307196781, 0.00452098527825, 0.00466064606118, 0.00479325608498, 0.00491376035745, 0.00503930226013, + 0.00514073539032, 0.00524611661324, 0.00534716811982, 0.00541967759307, 0.00548760401507, 0.00554757145088, 0.00559380230045, 0.00562206432097, + 0.00564551969164, 0.00563891995151, 0.00562661141932, 0.00559171286630, 0.00554043639400, 0.00547537830770, 0.00538389758970, 0.00527157587272, + 0.00513822754514, 0.00498396877629, 0.00481094690600, 0.00460395301471, 0.00438018617447, 0.00412516423270, 0.00384564081246, 0.00354012465507, + 0.00320918858098, 0.00284467578623, 0.00245085400321, 0.00202741761850, 0.00157846825768, 0.00109023290512, 0.00058322642480, 0.00002760451905, + -0.00054642808664, -0.00115681355227, -0.00180394725893, -0.00248267236449, -0.00319337783900, -0.00394011240522, -0.00472225962400, -0.00553372111088, + -0.00637922932685, -0.00726158168517, -0.00817982333726, -0.00913253296085, -0.01011502154986, -0.01113155480321, -0.01218499959508, -0.01327182200351, + 0.01439046660792, 0.01554055533423, 0.01673247129989, 0.01794333813443, 0.01918724313698, 0.02045317933555, 0.02174675502535, 0.02306801692862, + 0.02441609920285, 0.02578758475467, 0.02718594296329, 0.02860721736385, 0.03005026574279, 0.03150176087389, 0.03297540810337, 0.03446209487686, + 0.03596975605542, 0.03748128504252, 0.03900536794745, 0.04053491705584, 0.04206490946367, 0.04360975421304, 0.04514884056413, 0.04668430272642, + 0.04821657200672, 0.04973857556014, 0.05125561555216, 0.05276307465207, 0.05424527683589, 0.05571736482138, 0.05716164501299, 0.05859156836260, + 0.05998374801761, 0.06134551717207, 0.06268578081172, 0.06397158980681, 0.06522471064380, 0.06643675122104, 0.06760759851228, 0.06870438283512, + 0.06976302447127, 0.07076287107266, 0.07170026731102, 0.07256825833083, 0.07336202550803, 0.07410036424342, 0.07474525581194, 0.07531373362019, + 0.07580083586584, 0.07619924793396, 0.07649921704119, 0.07670934904245, 0.07681739756964, 0.07682300113923, 0.07672049241746, 0.07650507183194, + 0.07617483218536, 0.07573057565061, 0.07515762552870, 0.07446643947564, 0.07364060057620, 0.07267746427299, 0.07158263647903, 0.07035330735093, + 0.06896640131951, 0.06745250215166, 0.06576906686508, 0.06394448059633, 0.06196027790387, 0.05981665708090, 0.05751526919867, 0.05504600343009, + 0.05240938217366, 0.04959786763445, 0.04663033051701, 0.04347687821958, 0.04014582784127, 0.03664181168133, 0.03295839306691, 0.02908240060125, + 0.02503075618909, 0.02079970728622, 0.01637012582228, 0.01176238327857, 0.00696368621617, 0.00197656014503, -0.00320868968304, -0.00857117491366, + -0.01412888273558, -0.01988341292573, -0.02582272888064, -0.03195312745332, -0.03827765720822, -0.04478068215856, -0.05148041767934, -0.05837053268336, + -0.06544098531359, -0.07269433008129, -0.08013729344279, -0.08775475365593, -0.09555333528914, -0.10353295311463, -0.11168269317730, -0.12000779846800, + -0.12850028503878, -0.13715517611934, -0.14597664911870, -0.15496070710605, -0.16409588556669, -0.17338081721706, -0.18281725485142, -0.19239667457267, + -0.20212501768103, -0.21197358538056, -0.22196526964149, -0.23206908706791, -0.24230168845974, -0.25264803095722, -0.26310532994603, -0.27366340405625, + -0.28432141891085, -0.29507167170646, -0.30590985751916, -0.31682789136456, -0.32781137272105, -0.33887226938665, -0.34999141229310, -0.36115899031355, + 0.37237955463061, 0.38363500139043, 0.39492117615675, 0.40623176767625, 0.41756968968409, 0.42891199207373, 0.44025537543665, 0.45159965356824, + 0.46293080852757, 0.47424532146115, 0.48552530911099, 0.49677082545707, 0.50798175000434, 0.51912349702391, 0.53022408956855, 0.54125534487322, + 0.55220512585061, 0.56307891401370, 0.57385241316923, 0.58454032354679, 0.59511230862496, 0.60557835389180, 0.61591099320291, 0.62612426956055, + 0.63619801077286, 0.64612696959461, 0.65590163024671, 0.66551398801627, 0.67496631901712, 0.68423532934598, 0.69332823767032, 0.70223887193539, + 0.71094104263095, 0.71944626349561, 0.72774489002994, 0.73582117582769, 0.74368278636488, 0.75131374561237, 0.75870807608242, 0.76586748650939, + 0.77277808813327, 0.77942875190216, 0.78583531203920, 0.79197358416424, 0.79784664137700, 0.80344857518505, 0.80876950044491, 0.81381912706217, + 0.81857760046468, 0.82304198905409, 0.82722753473360, 0.83110384571520, 0.83469373618402, 0.83797173378865, 0.84095413924722, 0.84362382812005, + 0.84598184698206, 0.84803157770763, 0.84978051984268, 0.85119715249343, 0.85230470352147, 0.85310209497017, 0.85357205739107, 0.85373856005937, + 0.85357205739107, 0.85310209497017, 0.85230470352147, 0.85119715249343, 0.84978051984268, 0.84803157770763, 0.84598184698206, 0.84362382812005, + 0.84095413924722, 0.83797173378865, 0.83469373618402, 0.83110384571520, 0.82722753473360, 0.82304198905409, 0.81857760046468, 0.81381912706217, + 0.80876950044491, 0.80344857518505, 0.79784664137700, 0.79197358416424, 0.78583531203920, 0.77942875190216, 0.77277808813327, 0.76586748650939, + 0.75870807608242, 0.75131374561237, 0.74368278636488, 0.73582117582769, 0.72774489002994, 0.71944626349561, 0.71094104263095, 0.70223887193539, + 0.69332823767032, 0.68423532934598, 0.67496631901712, 0.66551398801627, 0.65590163024671, 0.64612696959461, 0.63619801077286, 0.62612426956055, + 0.61591099320291, 0.60557835389180, 0.59511230862496, 0.58454032354679, 0.57385241316923, 0.56307891401370, 0.55220512585061, 0.54125534487322, + 0.53022408956855, 0.51912349702391, 0.50798175000434, 0.49677082545707, 0.48552530911099, 0.47424532146115, 0.46293080852757, 0.45159965356824, + 0.44025537543665, 0.42891199207373, 0.41756968968409, 0.40623176767625, 0.39492117615675, 0.38363500139043, 0.37237955463061, 0.36115899031355, + -0.34999141229310, -0.33887226938665, -0.32781137272105, -0.31682789136456, -0.30590985751916, -0.29507167170646, -0.28432141891085, -0.27366340405625, + -0.26310532994603, -0.25264803095722, -0.24230168845974, -0.23206908706791, -0.22196526964149, -0.21197358538056, -0.20212501768103, -0.19239667457267, + -0.18281725485142, -0.17338081721706, -0.16409588556669, -0.15496070710605, -0.14597664911870, -0.13715517611934, -0.12850028503878, -0.12000779846800, + -0.11168269317730, -0.10353295311463, -0.09555333528914, -0.08775475365593, -0.08013729344279, -0.07269433008129, -0.06544098531359, -0.05837053268336, + -0.05148041767934, -0.04478068215856, -0.03827765720822, -0.03195312745332, -0.02582272888064, -0.01988341292573, -0.01412888273558, -0.00857117491366, + -0.00320868968304, 0.00197656014503, 0.00696368621617, 0.01176238327857, 0.01637012582228, 0.02079970728622, 0.02503075618909, 0.02908240060125, + 0.03295839306691, 0.03664181168133, 0.04014582784127, 0.04347687821958, 0.04663033051701, 0.04959786763445, 0.05240938217366, 0.05504600343009, + 0.05751526919867, 0.05981665708090, 0.06196027790387, 0.06394448059633, 0.06576906686508, 0.06745250215166, 0.06896640131951, 0.07035330735093, + 0.07158263647903, 0.07267746427299, 0.07364060057620, 0.07446643947564, 0.07515762552870, 0.07573057565061, 0.07617483218536, 0.07650507183194, + 0.07672049241746, 0.07682300113923, 0.07681739756964, 0.07670934904245, 0.07649921704119, 0.07619924793396, 0.07580083586584, 0.07531373362019, + 0.07474525581194, 0.07410036424342, 0.07336202550803, 0.07256825833083, 0.07170026731102, 0.07076287107266, 0.06976302447127, 0.06870438283512, + 0.06760759851228, 0.06643675122104, 0.06522471064380, 0.06397158980681, 0.06268578081172, 0.06134551717207, 0.05998374801761, 0.05859156836260, + 0.05716164501299, 0.05571736482138, 0.05424527683589, 0.05276307465207, 0.05125561555216, 0.04973857556014, 0.04821657200672, 0.04668430272642, + 0.04514884056413, 0.04360975421304, 0.04206490946367, 0.04053491705584, 0.03900536794745, 0.03748128504252, 0.03596975605542, 0.03446209487686, + 0.03297540810337, 0.03150176087389, 0.03005026574279, 0.02860721736385, 0.02718594296329, 0.02578758475467, 0.02441609920285, 0.02306801692862, + 0.02174675502535, 0.02045317933555, 0.01918724313698, 0.01794333813443, 0.01673247129989, 0.01554055533423, 0.01439046660792, 0.01327182200351, + -0.01218499959508, -0.01113155480321, -0.01011502154986, -0.00913253296085, -0.00817982333726, -0.00726158168517, -0.00637922932685, -0.00553372111088, + -0.00472225962400, -0.00394011240522, -0.00319337783900, -0.00248267236449, -0.00180394725893, -0.00115681355227, -0.00054642808664, 0.00002760451905, + 0.00058322642480, 0.00109023290512, 0.00157846825768, 0.00202741761850, 0.00245085400321, 0.00284467578623, 0.00320918858098, 0.00354012465507, + 0.00384564081246, 0.00412516423270, 0.00438018617447, 0.00460395301471, 0.00481094690600, 0.00498396877629, 0.00513822754514, 0.00527157587272, + 0.00538389758970, 0.00547537830770, 0.00554043639400, 0.00559171286630, 0.00562661141932, 0.00563891995151, 0.00564551969164, 0.00562206432097, + 0.00559380230045, 0.00554757145088, 0.00548760401507, 0.00541967759307, 0.00534716811982, 0.00524611661324, 0.00514073539032, 0.00503930226013, + 0.00491376035745, 0.00479325608498, 0.00466064606118, 0.00452098527825, 0.00437307196781, 0.00422642692270, 0.00408197531935, 0.00392074323703, + 0.00376039229104, 0.00360082681231, 0.00344188741828, 0.00327396134847, 0.00311254206525, 0.00294694477165, 0.00278704643465, 0.00262017586902, + 0.00246256169126, 0.00230172547746, 0.00214615835557, 0.00198411407369, 0.00183482654224, 0.00168680832531, 0.00154432198471, 0.00139024948272, + 0.00125778846475, 0.00112501551307, 0.00098859883015, 0.00086084433262, 0.00074580258865, 0.00062393761391, 0.00051073884952, 0.00040265402160, + 0.00029495311041, 0.00020430170688, 0.00010943831274, 0.00001349497418, -0.00006173344072, -0.00014463809349, -0.00020983373440, -0.00028969811748, + -0.00035011758756, -0.00040951214522, -0.00046063254803, -0.00051455722108, -0.00055645763906, -0.00059461189330, -0.00063415949025, -0.00066504150893, + -0.00069179375372, -0.00072153919876, -0.00073193571525, -0.00075300014201, -0.00076307935757, -0.00077579773310, -0.00078014496257, -0.00078036647100, + -0.00077798694927, -0.00078343322877, -0.00077248485949, -0.00076813719270, -0.00074905980532, -0.00074409418541, -0.00072550431222, -0.00071577364744, + -0.00069416146273, -0.00067776907764, -0.00065403333621, -0.00063124935319, -0.00061327473938, -0.00058709304852, -0.00056778025613, -0.00054665656337, + -0.00052265642972, -0.00050407143497, -0.00048937912498, -0.00048752279712, -0.00049475180896, -0.00056176925738, -0.00055252865047, 0.00000000000000, +]; + +/// The SBR noise table `V` (Table I.13): 512 unit-variance complex values (re, im). +#[rustfmt::skip] +pub(crate) static NOISE: [[f32; 2]; 512] = [ + [-0.99948153278296, -0.59483417516607], [0.97113454393991, -0.67528515225647], [0.14130051758487, -0.95090983575689], [-0.47005496701697, -0.37340549728647], + [0.80705063769351, 0.29653668284408], [-0.38981478896926, 0.89572605717087], [-0.01053049862020, -0.66959058036166], [-0.91266367957293, -0.11522938140034], + [0.54840422910309, 0.75221367176302], [0.40009252867955, -0.98929400334421], [-0.99867974711855, -0.88147068645358], [-0.95531076805040, 0.90908757154593], + [-0.45725933317144, -0.56716323646760], [-0.72929675029275, -0.98008272727324], [0.75622801399036, 0.20950329995549], [0.07069442601050, -0.78247898470706], + [0.74496252926055, -0.91169004445807], [-0.96440182703856, -0.94739918296622], [0.30424629369539, -0.49438267012479], [0.66565033746925, 0.64652935542491], + [0.91697008020594, 0.17514097332009], [-0.70774918760427, 0.52548653416543], [-0.70051415345560, -0.45340028808763], [-0.99496513054797, -0.90071908066973], + [0.98164490790123, -0.77463155528697], [-0.54671580548181, -0.02570928536004], [-0.01689629065389, 0.00287506445732], [-0.86110349531986, 0.42548583726477], + [-0.98892980586032, -0.87881132267556], [0.51756627678691, 0.66926784710139], [-0.99635026409640, -0.58107730574765], [-0.99969370862163, 0.98369989360250], + [0.55266258627194, 0.59449057465591], [0.34581177741673, 0.94879421061866], [0.62664209577999, -0.74402970906471], [-0.77149701404973, -0.33883658042801], + [-0.91592244254432, 0.03687901376713], [-0.76285492357887, -0.91371867919124], [0.79788337195331, -0.93180971199849], [0.54473080610200, -0.11919206037186], + [-0.85639281671058, 0.42429854760451], [-0.92882402971423, 0.27871809078609], [-0.11708371046774, -0.99800843444966], [0.21356749817493, -0.90716295627033], + [-0.76191692573909, 0.99768118356265], [0.98111043100884, -0.95854459734407], [-0.85913269895572, 0.95766566168880], [-0.93307242253692, 0.49431757696466], + [0.30485754879632, -0.70540034357529], [0.85289650925190, 0.46766131791044], [0.91328082618125, -0.99839597361769], [-0.05890199924154, 0.70741827819497], + [0.28398686150148, 0.34633555702188], [0.95258164539612, -0.54893416026939], [-0.78566324168507, -0.75568541079691], [-0.95789495447877, -0.20423194696966], + [0.82411158711197, 0.96654618432562], [-0.65185446735885, -0.88734990773289], [-0.93643603134666, 0.99870790442385], [0.91427159529618, -0.98290505544444], + [-0.70395684036886, 0.58796798221039], [0.00563771969365, 0.61768196727244], [0.89065051931895, 0.52783352697585], [-0.68683707712762, 0.80806944710339], + [0.72165342518718, -0.69259857349564], [-0.62928247730667, 0.13627037407335], [0.29938434065514, -0.46051329682246], [-0.91781958879280, -0.74012716684186], + [0.99298717043688, 0.40816610075661], [0.82368298622748, -0.74036047190173], [-0.98512833386833, -0.99972330709594], [-0.95915368242257, -0.99237800466040], + [-0.21411126572790, -0.93424819052545], [-0.68821476106884, -0.26892306315457], [0.91851997982317, 0.09358228901785], [-0.96062769559127, 0.36099095133739], + [0.51646184922287, -0.71373332873917], [0.61130721139669, 0.46950141175917], [0.47336129371299, -0.27333178296162], [0.90998308703519, 0.96715662938132], + [0.44844799194357, 0.99211574628306], [0.66614891079092, 0.96590176169121], [0.74922239129237, -0.89879858826087], [-0.99571588506485, 0.52785521494349], + [0.97401082477563, -0.16855870075190], [0.72683747733879, -0.48060774432251], [0.95432193457128, 0.68849603408441], [-0.72962208425191, -0.76608443420917], + [-0.85359479233537, 0.88738125901579], [-0.81412430338535, -0.97480768049637], [-0.87930772356786, 0.74748307690436], [-0.71573331064977, -0.98570608178923], + [0.83524300028228, 0.83702537075163], [-0.48086065601423, -0.98848504923531], [0.97139128574778, 0.80093621198236], [0.51992825347895, 0.80247631400510], + [-0.00848591195325, -0.76670128000486], [-0.70294374303036, 0.55359910445577], [-0.95894428168140, -0.43265504344783], [0.97079252950321, 0.09325857238682], + [-0.92404293670797, 0.85507704027855], [-0.69506469500450, 0.98633412625459], [0.26559203620024, 0.73314307966524], [0.28038443336943, 0.14537913654427], + [-0.74138124825523, 0.99310339807762], [-0.01752795995444, -0.82616635284178], [-0.55126773094930, -0.98898543862153], [0.97960898850996, -0.94021446752851], + [-0.99196309146936, 0.67019017358456], [-0.67684928085260, 0.12631491649378], [0.09140039465500, -0.20537731453108], [-0.71658965751996, -0.97788200391224], + [0.81014640078925, 0.53722648362443], [0.40616991671205, -0.26469008598449], [-0.67680188682972, 0.94502052337695], [0.86849774348749, -0.18333598647899], + [-0.99500381284851, -0.02634122068550], [0.84329189340667, 0.10406957462213], [-0.09215968531446, 0.69540012101253], [0.99956173327206, -0.12358542001404], + [-0.79732779473535, -0.91582524736159], [0.96349973642406, 0.96640458041000], [-0.79942778496547, 0.64323902822857], [-0.11566039853896, 0.28587846253726], + [-0.39922954514662, 0.94129601616966], [0.99089197565987, -0.92062625581587], [0.28631285179909, -0.91035047143603], [-0.83302725605608, -0.67330410892084], + [0.95404443402072, 0.49162765398743], [-0.06449863579434, 0.03250560813135], [-0.99575054486311, 0.42389784469507], [-0.65501142790847, 0.82546114655624], + [-0.81254441908887, -0.51627234660629], [-0.99646369485481, 0.84490533520752], [0.00287840603348, 0.64768261158166], [0.70176989408455, -0.20453028573322], + [0.96361882270190, 0.40706967140989], [-0.68883758192426, 0.91338958840772], [-0.34875585502238, 0.71472290693300], [0.91980081243087, 0.66507455644919], + [-0.99009048343881, 0.85868021604848], [0.68865791458395, 0.55660316809678], [-0.99484402129368, -0.20052559254934], [0.94214511408023, -0.99696425367461], + [-0.67414626793544, 0.49548221180078], [-0.47339353684664, -0.85904328834047], [0.14323651387360, -0.94145598222488], [-0.29268293575672, 0.05759224927952], + [0.43793861458754, -0.78904969892724], [-0.36345126374441, 0.64874435357162], [-0.08750604656825, 0.97686944362527], [-0.96495267812511, -0.53960305946511], + [0.55526940659947, 0.78891523734774], [0.73538215752630, 0.96452072373404], [-0.30889773919437, -0.80664389776860], [0.03574995626194, -0.97325616900959], + [0.98720684660488, 0.48409133691962], [-0.81689296271203, -0.90827703628298], [0.67866860118215, 0.81284503870856], [-0.15808569732583, 0.85279555024382], + [0.80723395114371, -0.24717418514605], [0.47788757329038, -0.46333147839295], [0.96367554763201, 0.38486749303242], [-0.99143875716818, -0.24945277239809], + [0.83081876925833, -0.94780851414763], [-0.58753191905341, 0.01290772389163], [0.95538108220960, -0.85557052096538], [-0.96490920476211, -0.64020970923102], + [-0.97327101028521, 0.12378128133110], [0.91400366022124, 0.57972471346930], [-0.99925837363824, 0.71084847864067], [-0.86875903507313, -0.20291699203564], + [-0.26240034795124, -0.68264554369108], [-0.24664412953388, -0.87642273115183], [0.02416275806869, 0.27192914288905], [0.82068619590515, -0.85087787994476], + [0.88547373760759, -0.89636802901469], [-0.18173078152226, -0.26152145156800], [0.09355476558534, 0.54845123045604], [-0.54668414224090, 0.95980774020221], + [0.37050990604091, -0.59910140383171], [-0.70373594262891, 0.91227665827081], [-0.34600785879594, -0.99441426144200], [-0.68774481731008, -0.30238837956299], + [-0.26843291251234, 0.83115668004362], [0.49072334613242, -0.45359708737775], [0.38975993093975, 0.95515358099121], [-0.97757125224150, 0.05305894580606], + [-0.17325552859616, -0.92770672250494], [0.99948035025744, 0.58285545563426], [-0.64946246527458, 0.68645507104960], [-0.12016920576437, -0.57147322153312], + [-0.58947456517751, -0.34847132454388], [-0.41815140454465, 0.16276422358861], [0.99885650204884, 0.11136095490444], [-0.56649614128386, -0.90494866361587], + [0.94138021032330, 0.35281916733018], [-0.75725076534641, 0.53650549640587], [0.20541973692630, -0.94435144369918], [0.99980371023351, 0.79835913565599], + [0.29078277605775, 0.35393777921520], [-0.62858772103030, 0.38765693387102], [0.43440904467688, -0.98546330463232], [-0.98298583762390, 0.21021524625209], + [0.19513029146934, -0.94239832251867], [-0.95476662400101, 0.98364554179143], [0.93379635304810, -0.70881994583682], [-0.85235410573336, -0.08342347966410], + [-0.86425093011245, -0.45795025029466], [0.38879779059045, 0.97274429344593], [0.92045124735495, -0.62433652524220], [0.89162532251878, 0.54950955570563], + [-0.36834336949252, 0.96458298020975], [0.93891760988045, -0.89968353740388], [0.99267657565094, -0.03757034316958], [-0.94063471614176, 0.41332338538963], + [0.99740224117019, -0.16830494996370], [-0.35899413170555, -0.46633226649613], [0.05237237274947, -0.25640361602661], [0.36703583957424, -0.38653265641875], + [0.91653180367913, -0.30587628726597], [0.69000803499316, 0.90952171386132], [-0.38658751133527, 0.99501571208985], [-0.29250814029851, 0.37444994344615], + [-0.60182204677608, 0.86779651036123], [-0.97418588163217, 0.96468523666475], [0.88461574003963, 0.57508405276414], [0.05198933055162, 0.21269661669964], + [-0.53499621979720, 0.97241553731237], [-0.49429560226497, 0.98183865291903], [-0.98935142339139, -0.40249159006933], [-0.98081380091130, -0.72856895534041], + [-0.27338148835532, 0.99950922447209], [0.06310802338302, -0.54539587529618], [-0.20461677199539, -0.14209977628489], [0.66223843141647, 0.72528579940326], + [-0.84764345483665, 0.02372316801261], [-0.89039863483811, 0.88866581484602], [0.95903308477986, 0.76744927173873], [0.73504123909879, -0.03747203173192], + [-0.31744434966056, -0.36834111883652], [-0.34110827591623, 0.40211222807691], [0.47803883714199, -0.39423219786288], [0.98299195879514, 0.01989791390047], + [-0.30963073129751, -0.18076720599336], [0.99992588229018, -0.26281872094289], [-0.93149731080767, -0.98313162570490], [0.99923472302773, -0.80142993767554], + [-0.26024169633417, -0.75999759855752], [-0.35712514743563, 0.19298963768574], [-0.99899084509530, 0.74645156992493], [0.86557171579452, 0.55593866696299], + [0.33408042438752, 0.86185953874709], [0.99010736374716, 0.04602397576623], [-0.66694269691195, -0.91643611810148], [0.64016792079480, 0.15649530836856], + [0.99570534804836, 0.45844586038111], [-0.63431466947340, 0.21079116459234], [-0.07706847005931, -0.89581437101329], [0.98590090577724, 0.88241721133981], + [0.80099335254678, -0.36851896710853], [0.78368131392666, 0.45506999802597], [0.08707806671691, 0.80938994918745], [-0.86811883080712, 0.39347308654705], + [-0.39466529740375, -0.66809432114456], [0.97875325649683, -0.72467840967746], [-0.95038560288864, 0.89563219587625], [0.17005239424212, 0.54683053962658], + [-0.76910792026848, -0.96226617549298], [0.99743281016846, 0.42697157037567], [0.95437383549973, 0.97002324109952], [0.99578905365569, -0.54106826257356], + [0.28058259829990, -0.85361420634036], [0.85256524470573, -0.64567607735589], [-0.50608540105128, -0.65846015480300], [-0.97210735183243, -0.23095213067791], + [0.95424048234441, -0.99240147091219], [-0.96926570524023, 0.73775654896574], [0.30872163214726, 0.41514960556126], [-0.24523839572639, 0.63206633394807], + [-0.33813265086024, -0.38661779441897], [-0.05826828420146, -0.06940774188029], [-0.22898461455054, 0.97054853316316], [-0.18509915019881, 0.47565762892084], + [-0.10488238045009, -0.87769947402394], [-0.71886586182037, 0.78030982480538], [0.99793873738654, 0.90041310491497], [0.57563307626120, -0.91034337352097], + [0.28909646383717, 0.96307783970534], [0.42188998312520, 0.48148651230437], [0.93335049681047, -0.43537023883588], [-0.97087374418267, 0.86636445711364], + [0.36722871286923, 0.65291654172961], [-0.81093025665696, 0.08778370229363], [-0.26240603062237, -0.92774095379098], [0.83996497984604, 0.55839849139647], + [-0.99909615720225, -0.96024605713970], [0.74649464155061, 0.12144893606462], [-0.74774595569805, -0.26898062008959], [0.95781667469567, -0.79047927052628], + [0.95472308713099, -0.08588776019550], [0.48708332746299, 0.99999041579432], [0.46332038247497, 0.10964126185063], [-0.76497004940162, 0.89210929242238], + [0.57397389364339, 0.35289703373760], [0.75374316974495, 0.96705214651335], [-0.59174397685714, -0.89405370422752], [0.75087906691890, -0.29612672982396], + [-0.98607857336230, 0.25034911730023], [-0.40761056640505, -0.90045573444695], [0.66929266740477, 0.98629493401748], [-0.97463695257310, -0.00190223301301], + [0.90145509409859, 0.99781390365446], [-0.87259289048043, 0.99233587353666], [-0.91529461447692, -0.15698707534206], [-0.03305738840705, -0.37205262859764], + [0.07223051368337, -0.88805001733626], [0.99498012188353, 0.97094358113387], [-0.74904939500519, 0.99985483641521], [0.04585228574211, 0.99812337444082], + [-0.89054954257993, -0.31791913188064], [-0.83782144651251, 0.97637632547466], [0.33454804933804, -0.86231516800408], [-0.99707579362824, 0.93237990079441], + [-0.22827527843994, 0.18874759397997], [0.67248046289143, -0.03646211390569], [-0.05146538187944, -0.92599700120679], [0.99947295749905, 0.93625229707912], + [0.66951124390363, 0.98905825623893], [-0.99602956559179, -0.44654715757688], [0.82104905483590, 0.99540741724928], [0.99186510988782, 0.72023001312947], + [-0.65284592392918, 0.52186723253637], [0.93885443798188, -0.74895312615259], [0.96735248738388, 0.90891816978629], [-0.22225968841114, 0.57124029781228], + [-0.44132783753414, -0.92688840659280], [-0.85694974219574, 0.88844532719844], [0.91783042091762, -0.46356892383970], [0.72556974415690, -0.99899555770747], + [-0.99711581834508, 0.58211560180426], [0.77638976371966, 0.94321834873819], [0.07717324253925, 0.58638399856595], [-0.56049829194163, 0.82522301569036], + [0.98398893639988, 0.39467440420569], [0.47546946844938, 0.68613044836811], [0.65675089314631, 0.18331637134880], [0.03273375457980, -0.74933109564108], + [-0.38684144784738, 0.51337349030406], [-0.97346267944545, -0.96549364384098], [-0.53282156061942, -0.91423265091354], [0.99817310731176, 0.61133572482148], + [-0.50254500772635, -0.88829338134294], [0.01995873238855, 0.85223515096765], [0.99930381973804, 0.94578896296649], [0.82907767600783, -0.06323442598128], + [-0.58660709669728, 0.96840773806582], [-0.17573736667267, -0.48166920859485], [0.83434292401346, -0.13023450646997], [0.05946491307025, 0.20511047074866], + [0.81505484574602, -0.94685947861369], [-0.44976380954860, 0.40894572671545], [-0.89746474625671, 0.99846578838537], [0.39677256130792, -0.74854668609359], + [-0.07588948563079, 0.74096214084170], [0.76343198951445, 0.41746629422634], [-0.74490104699626, 0.94725911744610], [0.64880119792759, 0.41336660830571], + [0.62319537462542, -0.93098313552599], [0.42215817594807, -0.07712787385208], [0.02704554141885, -0.05417518053666], [0.80001773566818, 0.91542195141039], + [-0.79351832348816, -0.36208897989136], [0.63872359151636, 0.08128252493444], [0.52890520960295, 0.60048872455592], [0.74238552914587, 0.04491915291044], + [0.99096131449250, -0.19451182854402], [-0.80412329643109, -0.88513818199457], [-0.64612616129736, 0.72198674804544], [0.11657770663191, -0.83662833815041], + [-0.95053182488101, -0.96939905138082], [-0.62228872928622, 0.82767262846661], [0.03004475787316, -0.99738896333384], [-0.97987214341034, 0.36526129686425], + [-0.99986980746200, -0.36021610299715], [0.89110648599879, -0.97894250343044], [0.10407960510582, 0.77357793811619], [0.95964737821728, -0.35435818285502], + [0.50843233159162, 0.96107691266205], [0.17006334670615, -0.76854025314829], [0.25872675063360, 0.99893303933816], [-0.01115998681937, 0.98496019742444], + [-0.79598702973261, 0.97138411318894], [-0.99264708948101, -0.99542822402536], [-0.99829663752818, 0.01877138824311], [-0.70801016548184, 0.33680685948117], + [-0.70467057786826, 0.93272777501857], [0.99846021905254, -0.98725746254433], [-0.63364968534650, -0.16473594423746], [-0.16258217500792, -0.95939125400802], + [-0.43645594360633, -0.94805030113284], [-0.99848471702976, 0.96245166923809], [-0.16796458968998, -0.98987511890470], [-0.87979225745213, -0.71725725041680], + [0.44183099021786, -0.93568974498761], [0.93310180125532, -0.99913308068246], [-0.93941931782002, -0.56409379640356], [-0.88590003188677, 0.47624600491382], + [0.99971463703691, -0.83889954253462], [-0.75376385639978, 0.00814643438625], [0.93887685615875, -0.11284528204636], [0.85126435782309, 0.52349251543547], + [0.39701421446381, 0.81779634174316], [-0.37024464187437, -0.87071656222959], [-0.36024828242896, 0.34655735648287], [-0.93388812549209, -0.84476541096429], + [-0.65298804552119, -0.18439575450921], [0.11960319006843, 0.99899346780168], [0.94292565553160, 0.83163906518293], [0.75081145286948, -0.35533223142265], + [0.56721979748394, -0.24076836414499], [0.46857766746029, -0.30140233457198], [0.97312313923635, -0.99548191630031], [-0.38299976567017, 0.98516909715427], + [0.41025800019463, 0.02116736935734], [0.09638062008048, 0.04411984381457], [-0.85283249275397, 0.91475563922421], [0.88866808958124, -0.99735267083226], + [-0.48202429536989, -0.96805608884164], [0.27572582416567, 0.58634753335832], [-0.65889129659168, 0.58835634138583], [0.98838086953732, 0.99994349600236], + [-0.20651349620689, 0.54593044066355], [-0.62126416356920, -0.59893681700392], [0.20320105410437, -0.86879180355289], [-0.97790548600584, 0.96290806999242], + [0.11112534735126, 0.21484763313301], [-0.41368337314182, 0.28216837680365], [0.24133038992960, 0.51294362630238], [-0.66393410674885, -0.08249679629081], + [-0.53697829178752, -0.97649903936228], [-0.97224737889348, 0.22081333579837], [0.87392477144549, -0.12796173740361], [0.19050361015753, 0.01602615387195], + [-0.46353441212724, -0.95249041539006], [-0.07064096339021, -0.94479803205886], [-0.92444085484466, -0.10457590187436], [-0.83822593578728, -0.01695043208885], + [0.75214681811150, -0.99955681042665], [-0.42102998829339, 0.99720941999394], [-0.72094786237696, -0.35008961934255], [0.78843311019251, 0.52851398958271], + [0.97394027897442, -0.26695944086561], [0.99206463477946, -0.57010120849429], [0.76789609461795, -0.76519356730966], [-0.82002421836409, -0.73530179553767], + [0.81924990025724, 0.99698425250579], [-0.26719850873357, 0.68903369776193], [-0.43311260380975, 0.85321815947490], [0.99194979673836, 0.91876249766422], + [-0.80692001248487, -0.32627540663214], [0.43080003649976, -0.21919095636638], [0.67709491937357, -0.95478075822906], [0.56151770568316, -0.70693811747778], + [0.10831862810749, -0.08628837174592], [0.91229417540436, -0.65987351408410], [-0.48972893932274, 0.56289246362686], [-0.89033658689697, -0.71656563987082], + [0.65269447475094, 0.65916004833932], [0.67439478141121, -0.81684380846796], [-0.47770832416973, -0.16789556203025], [-0.99715979260878, -0.93565784007648], + [-0.90889593602546, 0.62034397054380], [-0.06618622548177, -0.23812217221359], [0.99430266919728, 0.18812555317553], [0.97686402381843, -0.28664534366620], + [0.94813650221268, -0.97506640027128], [-0.95434497492853, -0.79607978501983], [-0.49104783137150, 0.32895214359663], [0.99881175120751, 0.88993983831354], + [0.50449166760303, -0.85995072408434], [0.47162891065108, -0.18680204049569], [-0.62081581361840, 0.75000676218956], [-0.43867015250812, 0.99998069244322], + [0.98630563232075, -0.53578899600662], [-0.61510362277374, -0.89515019899997], [-0.03841517601843, -0.69888815681179], [-0.30102157304644, -0.07667808922205], + [0.41881284182683, 0.02188098922282], [-0.86135454941237, 0.98947480909359], [0.67226861393788, -0.13494389011014], [-0.70737398842068, -0.76547349325992], + [0.94044946687963, 0.09026201157416], [-0.82386352534327, 0.08924768823676], [-0.32070666698656, 0.50143421908753], [0.57593163224487, -0.98966422921509], + [-0.36326018419965, 0.07440243123228], [0.99979044674350, -0.14130287347405], [-0.92366023326932, -0.97979298068180], [-0.44607178518598, -0.54233252016394], + [0.44226800932956, 0.71326756742752], [0.03671907158312, 0.63606389366675], [0.52175424682195, -0.85396826735705], [-0.94701139690956, -0.01826348194255], + [-0.98759606946049, 0.82288714303073], [0.87434794743625, 0.89399495655433], [-0.93412041758744, 0.41374052024363], [0.96063943315511, 0.93116709541280], + [0.97534253457837, 0.86150930812689], [0.99642466504163, 0.70190043427512], [-0.94705089665984, -0.29580042814306], [0.91599807087376, -0.98147830385781], +]; + +/// `t_huffman_env_1_5dB` (`(codeword, length)` by index): envelope, time direction, 1.5 dB steps (LAV 60). +#[rustfmt::skip] +pub(crate) static T_ENV_1_5DB: [(u32, u8); 121] = [ + (0x3ffd6, 18), (0x3ffd7, 18), (0x3ffd8, 18), (0x3ffd9, 18), (0x3ffda, 18), (0x3ffdb, 18), (0x7ffb8, 19), (0x7ffb9, 19), + (0x7ffba, 19), (0x7ffbb, 19), (0x7ffbc, 19), (0x7ffbd, 19), (0x7ffbe, 19), (0x7ffbf, 19), (0x7ffc0, 19), (0x7ffc1, 19), + (0x7ffc2, 19), (0x7ffc3, 19), (0x7ffc4, 19), (0x7ffc5, 19), (0x7ffc6, 19), (0x7ffc7, 19), (0x7ffc8, 19), (0x7ffc9, 19), + (0x7ffca, 19), (0x7ffcb, 19), (0x7ffcc, 19), (0x7ffcd, 19), (0x7ffce, 19), (0x7ffcf, 19), (0x7ffd0, 19), (0x7ffd1, 19), + (0x7ffd2, 19), (0x7ffd3, 19), (0x1ffe6, 17), (0x3ffd4, 18), (0xfff0, 16), (0x1ffe9, 17), (0x3ffd5, 18), (0x1ffe7, 17), + (0xfff1, 16), (0xffec, 16), (0xffed, 16), (0xffee, 16), (0x7ff4, 15), (0x3ff9, 14), (0x3ff7, 14), (0x1ffa, 13), + (0x1ff9, 13), (0xffb, 12), (0x7fc, 11), (0x3fc, 10), (0x1fd, 9), (0xfd, 8), (0x7d, 7), (0x3d, 6), + (0x1d, 5), (0xd, 4), (0x5, 3), (0x1, 2), (0x0, 2), (0x4, 3), (0xc, 4), (0x1c, 5), + (0x3c, 6), (0x7c, 7), (0xfc, 8), (0x1fc, 9), (0x3fd, 10), (0xffa, 12), (0x1ff8, 13), (0x3ff6, 14), + (0x3ff8, 14), (0x7ff5, 15), (0xffef, 16), (0x1ffe8, 17), (0xfff2, 16), (0x7ffd4, 19), (0x7ffd5, 19), (0x7ffd6, 19), + (0x7ffd7, 19), (0x7ffd8, 19), (0x7ffd9, 19), (0x7ffda, 19), (0x7ffdb, 19), (0x7ffdc, 19), (0x7ffdd, 19), (0x7ffde, 19), + (0x7ffdf, 19), (0x7ffe0, 19), (0x7ffe1, 19), (0x7ffe2, 19), (0x7ffe3, 19), (0x7ffe4, 19), (0x7ffe5, 19), (0x7ffe6, 19), + (0x7ffe7, 19), (0x7ffe8, 19), (0x7ffe9, 19), (0x7ffea, 19), (0x7ffeb, 19), (0x7ffec, 19), (0x7ffed, 19), (0x7ffee, 19), + (0x7ffef, 19), (0x7fff0, 19), (0x7fff1, 19), (0x7fff2, 19), (0x7fff3, 19), (0x7fff4, 19), (0x7fff5, 19), (0x7fff6, 19), + (0x7fff7, 19), (0x7fff8, 19), (0x7fff9, 19), (0x7fffa, 19), (0x7fffb, 19), (0x7fffc, 19), (0x7fffd, 19), (0x7fffe, 19), + (0x7ffff, 19), +]; + +/// `f_huffman_env_1_5dB` (`(codeword, length)` by index): envelope, frequency direction, 1.5 dB steps (LAV 60). +#[rustfmt::skip] +pub(crate) static F_ENV_1_5DB: [(u32, u8); 121] = [ + (0x7ffe7, 19), (0x7ffe8, 19), (0xfffd2, 20), (0xfffd3, 20), (0xfffd4, 20), (0xfffd5, 20), (0xfffd6, 20), (0xfffd7, 20), + (0xfffd8, 20), (0x7ffda, 19), (0xfffd9, 20), (0xfffda, 20), (0xfffdb, 20), (0xfffdc, 20), (0x7ffdb, 19), (0xfffdd, 20), + (0x7ffdc, 19), (0x7ffdd, 19), (0xfffde, 20), (0x3ffe4, 18), (0xfffdf, 20), (0xfffe0, 20), (0xfffe1, 20), (0x7ffde, 19), + (0xfffe2, 20), (0xfffe3, 20), (0xfffe4, 20), (0x7ffdf, 19), (0xfffe5, 20), (0x7ffe0, 19), (0x3ffe8, 18), (0x7ffe1, 19), + (0x3ffe0, 18), (0x3ffe9, 18), (0x1ffef, 17), (0x3ffe5, 18), (0x1ffec, 17), (0x1ffed, 17), (0x1ffee, 17), (0xfff4, 16), + (0xfff3, 16), (0xfff0, 16), (0x7ff7, 15), (0x7ff6, 15), (0x3ffa, 14), (0x1ffa, 13), (0x1ff9, 13), (0xffa, 12), + (0xff8, 12), (0x7f9, 11), (0x3fb, 10), (0x1fc, 9), (0x1fa, 9), (0xfb, 8), (0x7c, 7), (0x3c, 6), + (0x1c, 5), (0xc, 4), (0x5, 3), (0x1, 2), (0x0, 2), (0x4, 3), (0xd, 4), (0x1d, 5), + (0x3d, 6), (0xfa, 8), (0xfc, 8), (0x1fb, 9), (0x3fa, 10), (0x7f8, 11), (0x7fa, 11), (0x7fb, 11), + (0xff9, 12), (0xffb, 12), (0x1ff8, 13), (0x1ffb, 13), (0x3ff8, 14), (0x3ff9, 14), (0xfff1, 16), (0xfff2, 16), + (0x1ffea, 17), (0x1ffeb, 17), (0x3ffe1, 18), (0x3ffe2, 18), (0x3ffea, 18), (0x3ffe3, 18), (0x3ffe6, 18), (0x3ffe7, 18), + (0x3ffeb, 18), (0xfffe6, 20), (0x7ffe2, 19), (0xfffe7, 20), (0xfffe8, 20), (0xfffe9, 20), (0xfffea, 20), (0xfffeb, 20), + (0xfffec, 20), (0x7ffe3, 19), (0xfffed, 20), (0xfffee, 20), (0xfffef, 20), (0xffff0, 20), (0x7ffe4, 19), (0xffff1, 20), + (0x3ffec, 18), (0xffff2, 20), (0xffff3, 20), (0x7ffe5, 19), (0x7ffe6, 19), (0xffff4, 20), (0xffff5, 20), (0xffff6, 20), + (0xffff7, 20), (0xffff8, 20), (0xffff9, 20), (0xffffa, 20), (0xffffb, 20), (0xffffc, 20), (0xffffd, 20), (0xffffe, 20), + (0xfffff, 20), +]; + +/// `t_huffman_env_bal_1_5dB` (`(codeword, length)` by index): envelope balance, time direction, 1.5 dB steps (LAV 24). +#[rustfmt::skip] +pub(crate) static T_ENV_BAL_1_5DB: [(u32, u8); 49] = [ + (0xffe4, 16), (0xffe5, 16), (0xffe6, 16), (0xffe7, 16), (0xffe8, 16), (0xffe9, 16), (0xffea, 16), (0xffeb, 16), + (0xffec, 16), (0xffed, 16), (0xffee, 16), (0xffef, 16), (0xfff0, 16), (0xfff1, 16), (0xfff2, 16), (0xfff3, 16), + (0xfff4, 16), (0xffe2, 16), (0xffc, 12), (0x7fc, 11), (0x1fe, 9), (0x7e, 7), (0x1e, 5), (0x6, 3), + (0x0, 1), (0x2, 2), (0xe, 4), (0x3e, 6), (0xfe, 8), (0x7fd, 11), (0xffd, 12), (0x7ff0, 15), + (0xffe3, 16), (0xfff5, 16), (0xfff6, 16), (0xfff7, 16), (0xfff8, 16), (0xfff9, 16), (0xfffa, 16), (0x1fff6, 17), + (0x1fff7, 17), (0x1fff8, 17), (0x1fff9, 17), (0x1fffa, 17), (0x1fffb, 17), (0x1fffc, 17), (0x1fffd, 17), (0x1fffe, 17), + (0x1ffff, 17), +]; + +/// `f_huffman_env_bal_1_5dB` (`(codeword, length)` by index): envelope balance, frequency direction, 1.5 dB steps (LAV 24). +#[rustfmt::skip] +pub(crate) static F_ENV_BAL_1_5DB: [(u32, u8); 49] = [ + (0x3ffe2, 18), (0x3ffe3, 18), (0x3ffe4, 18), (0x3ffe5, 18), (0x3ffe6, 18), (0x3ffe7, 18), (0x3ffe8, 18), (0x3ffe9, 18), + (0x3ffea, 18), (0x3ffeb, 18), (0x3ffec, 18), (0x3ffed, 18), (0x3ffee, 18), (0x3ffef, 18), (0x3fff0, 18), (0xfff7, 16), + (0x1fff0, 17), (0x3ffc, 14), (0x7fe, 11), (0x7fc, 11), (0xfe, 8), (0x7e, 7), (0xe, 4), (0x2, 2), + (0x0, 1), (0x6, 3), (0x1e, 5), (0x3e, 6), (0x1fe, 9), (0x7fd, 11), (0xffe, 12), (0x7ffa, 15), + (0xfff6, 16), (0x3fff1, 18), (0x3fff2, 18), (0x3fff3, 18), (0x3fff4, 18), (0x3fff5, 18), (0x3fff6, 18), (0x3fff7, 18), + (0x3fff8, 18), (0x3fff9, 18), (0x3fffa, 18), (0x3fffb, 18), (0x3fffc, 18), (0x3fffd, 18), (0x3fffe, 18), (0x7fffe, 19), + (0x7ffff, 19), +]; + +/// `t_huffman_env_3_0dB` (`(codeword, length)` by index): envelope, time direction, 3 dB steps (LAV 31). +#[rustfmt::skip] +pub(crate) static T_ENV_3_0DB: [(u32, u8); 63] = [ + (0x3ffed, 18), (0x3ffee, 18), (0x7ffde, 19), (0x7ffdf, 19), (0x7ffe0, 19), (0x7ffe1, 19), (0x7ffe2, 19), (0x7ffe3, 19), + (0x7ffe4, 19), (0x7ffe5, 19), (0x7ffe6, 19), (0x7ffe7, 19), (0x7ffe8, 19), (0x7ffe9, 19), (0x7ffea, 19), (0x7ffeb, 19), + (0x7ffec, 19), (0x1fff4, 17), (0xfff7, 16), (0xfff9, 16), (0xfff8, 16), (0x3ffb, 14), (0x3ffa, 14), (0x3ff8, 14), + (0x1ffa, 13), (0xffc, 12), (0x7fc, 11), (0xfe, 8), (0x3e, 6), (0xe, 4), (0x2, 2), (0x0, 1), + (0x6, 3), (0x1e, 5), (0x7e, 7), (0x1fe, 9), (0x7fd, 11), (0x1ffb, 13), (0x3ff9, 14), (0x3ffc, 14), + (0x7ffa, 15), (0xfff6, 16), (0x1fff5, 17), (0x3ffec, 18), (0x7ffed, 19), (0x7ffee, 19), (0x7ffef, 19), (0x7fff0, 19), + (0x7fff1, 19), (0x7fff2, 19), (0x7fff3, 19), (0x7fff4, 19), (0x7fff5, 19), (0x7fff6, 19), (0x7fff7, 19), (0x7fff8, 19), + (0x7fff9, 19), (0x7fffa, 19), (0x7fffb, 19), (0x7fffc, 19), (0x7fffd, 19), (0x7fffe, 19), (0x7ffff, 19), +]; + +/// `f_huffman_env_3_0dB` (`(codeword, length)` by index): envelope and noise floor, frequency direction, 3 dB steps (LAV 31). +#[rustfmt::skip] +pub(crate) static F_ENV_3_0DB: [(u32, u8); 63] = [ + (0xffff0, 20), (0xffff1, 20), (0xffff2, 20), (0xffff3, 20), (0xffff4, 20), (0xffff5, 20), (0xffff6, 20), (0x3fff3, 18), + (0x7fff5, 19), (0x7ffee, 19), (0x7ffef, 19), (0x7fff6, 19), (0x3fff4, 18), (0x3fff2, 18), (0xffff7, 20), (0x7fff0, 19), + (0x1fff5, 17), (0x3fff0, 18), (0x1fff4, 17), (0xfff7, 16), (0xfff6, 16), (0x7ff8, 15), (0x3ffb, 14), (0xffd, 12), + (0x7fd, 11), (0x3fd, 10), (0x1fd, 9), (0xfd, 8), (0x3e, 6), (0xe, 4), (0x2, 2), (0x0, 1), + (0x6, 3), (0x1e, 5), (0xfc, 8), (0x1fc, 9), (0x3fc, 10), (0x7fc, 11), (0xffc, 12), (0x1ffc, 13), + (0x3ffa, 14), (0x7ff9, 15), (0x7ffa, 15), (0xfff8, 16), (0xfff9, 16), (0x1fff6, 17), (0x1fff7, 17), (0x3fff5, 18), + (0x3fff6, 18), (0x3fff1, 18), (0xffff8, 20), (0x7fff1, 19), (0x7fff2, 19), (0x7fff3, 19), (0xffff9, 20), (0x7fff7, 19), + (0x7fff4, 19), (0xffffa, 20), (0xffffb, 20), (0xffffc, 20), (0xffffd, 20), (0xffffe, 20), (0xfffff, 20), +]; + +/// `t_huffman_env_bal_3_0dB` (`(codeword, length)` by index): envelope balance, time direction, 3 dB steps (LAV 12). +#[rustfmt::skip] +pub(crate) static T_ENV_BAL_3_0DB: [(u32, u8); 25] = [ + (0x1ff2, 13), (0x1ff3, 13), (0x1ff4, 13), (0x1ff5, 13), (0x1ff6, 13), (0x1ff7, 13), (0x1ff8, 13), (0xff8, 12), + (0xfe, 8), (0x7e, 7), (0xe, 4), (0x6, 3), (0x0, 1), (0x2, 2), (0x1e, 5), (0x3e, 6), + (0x1fe, 9), (0x1ff9, 13), (0x1ffa, 13), (0x1ffb, 13), (0x1ffc, 13), (0x1ffd, 13), (0x1ffe, 13), (0x3ffe, 14), + (0x3fff, 14), +]; + +/// `f_huffman_env_bal_3_0dB` (`(codeword, length)` by index): envelope and noise floor balance, frequency direction, 3 dB steps (LAV 12). +#[rustfmt::skip] +pub(crate) static F_ENV_BAL_3_0DB: [(u32, u8); 25] = [ + (0x1ff7, 13), (0x1ff8, 13), (0x1ff9, 13), (0x1ffa, 13), (0x1ffb, 13), (0x3ff8, 14), (0x3ff9, 14), (0x7fc, 11), + (0xfe, 8), (0x7e, 7), (0xe, 4), (0x2, 2), (0x0, 1), (0x6, 3), (0x1e, 5), (0x3e, 6), + (0x1fe, 9), (0xffa, 12), (0x1ff6, 13), (0x3ffa, 14), (0x3ffb, 14), (0x3ffc, 14), (0x3ffd, 14), (0x3ffe, 14), + (0x3fff, 14), +]; + +/// `t_huffman_noise_3_0dB` (`(codeword, length)` by index): noise floor, time direction (LAV 31). +#[rustfmt::skip] +pub(crate) static T_NOISE_3_0DB: [(u32, u8); 63] = [ + (0x1fce, 13), (0x1fcf, 13), (0x1fd0, 13), (0x1fd1, 13), (0x1fd2, 13), (0x1fd3, 13), (0x1fd4, 13), (0x1fd5, 13), + (0x1fd6, 13), (0x1fd7, 13), (0x1fd8, 13), (0x1fd9, 13), (0x1fda, 13), (0x1fdb, 13), (0x1fdc, 13), (0x1fdd, 13), + (0x1fde, 13), (0x1fdf, 13), (0x1fe0, 13), (0x1fe1, 13), (0x1fe2, 13), (0x1fe3, 13), (0x1fe4, 13), (0x1fe5, 13), + (0x1fe6, 13), (0x1fe7, 13), (0x7f2, 11), (0xfd, 8), (0x3e, 6), (0xe, 4), (0x6, 3), (0x0, 1), + (0x2, 2), (0x1e, 5), (0xfc, 8), (0x3f8, 10), (0x1fcc, 13), (0x1fe8, 13), (0x1fe9, 13), (0x1fea, 13), + (0x1feb, 13), (0x1fec, 13), (0x1fcd, 13), (0x1fed, 13), (0x1fee, 13), (0x1fef, 13), (0x1ff0, 13), (0x1ff1, 13), + (0x1ff2, 13), (0x1ff3, 13), (0x1ff4, 13), (0x1ff5, 13), (0x1ff6, 13), (0x1ff7, 13), (0x1ff8, 13), (0x1ff9, 13), + (0x1ffa, 13), (0x1ffb, 13), (0x1ffc, 13), (0x1ffd, 13), (0x1ffe, 13), (0x3ffe, 14), (0x3fff, 14), +]; + +/// `t_huffman_noise_bal_3_0dB` (`(codeword, length)` by index): noise floor balance, time direction (LAV 12). +#[rustfmt::skip] +pub(crate) static T_NOISE_BAL_3_0DB: [(u32, u8); 25] = [ + (0xec, 8), (0xed, 8), (0xee, 8), (0xef, 8), (0xf0, 8), (0xf1, 8), (0xf2, 8), (0xf3, 8), + (0xf4, 8), (0xf5, 8), (0x1c, 5), (0x2, 2), (0x0, 1), (0x6, 3), (0x3a, 6), (0xf6, 8), + (0xf7, 8), (0xf8, 8), (0xf9, 8), (0xfa, 8), (0xfb, 8), (0xfc, 8), (0xfd, 8), (0xfe, 8), + (0xff, 8), +]; diff --git a/crates/audio/src/track.rs b/crates/audio/src/track.rs index 920f571ae..e522e6f68 100644 --- a/crates/audio/src/track.rs +++ b/crates/audio/src/track.rs @@ -24,8 +24,10 @@ pub const LOOKAHEAD_PACKETS: usize = 8; enum Inner { #[cfg(feature = "aac-decode")] Aac { + /// The decoder, with SBR on for HE-AAC. dec: Box, - up: Option, + /// HE-AAC: 2048 samples per access unit, at twice the core rate. + sbr: bool, order: Option<&'static [usize]>, /// The format ADTS packets must keep, when their headers gave the configuration. adts_format: Option, @@ -74,7 +76,7 @@ pub(crate) type PacketFrames = fn(&[u8], u64) -> Option; /// /// It takes the track's [`CodecParams`] (from `isobmff::Track::codec_params`, /// `matroska::Track::codec_params`, `ogg::Stream::codec_params`, or built by hand for another -/// container) and decodes AAC (LC; HE-AAC as its upsampled LC core), ALAC, Opus, AC-3/E-AC-3, +/// container) and decodes AAC (LC, and HE-AAC with its SBR high band), ALAC, Opus, AC-3/E-AC-3, /// MP1/MP2, MP3 and PCM. Container timing (AAC priming, Opus pre-skip, end trimming) is the caller's: /// every decoded sample is returned. /// @@ -168,17 +170,18 @@ impl TrackDecoder { params.extradata.clone() }; let asc = asc.as_slice(); - let dec = aac::Decoder::new(asc)?; + let mut dec = aac::Decoder::new(asc)?; let out = aac::output_rate(asc, payloads.iter().copied()).unwrap_or(dec.sample_rate()); - let up = (dec.sample_rate().checked_mul(2) == Some(out)).then(|| aac::Upsample2x::new(dec.channels())); - let codec = if up.is_some() { CodecId::HeAac } else { CodecId::Aac }; - // Only twice the core rate is upsampled to; any other signalled rate plays the core. - let out = if up.is_some() { out } else { dec.sample_rate() }; + let sbr = dec.sample_rate().checked_mul(2) == Some(out); + let codec = if sbr { CodecId::HeAac } else { CodecId::Aac }; + // Only SBR at twice the core rate is decoded; any other signalled rate plays the core. + dec.set_sbr(sbr); + let out = dec.sample_rate(); // a program config element that spells out a configuration has its speakers let cfg = aac::speaker_config(dec.config()); let layout = aac_layout(cfg, dec.channels()); let order = layout.is_ordered().then(|| aac::wav_order(cfg)).flatten(); - (Inner::Aac { dec: Box::new(dec), up, order, adts_format }, codec, out, layout, SampleFormat::Compressed) + (Inner::Aac { dec: Box::new(dec), sbr, order, adts_format }, codec, out, layout, SampleFormat::Compressed) } #[cfg(feature = "alac-decode")] CodecId::Alac => { @@ -320,7 +323,7 @@ impl TrackDecoder { let mut partial = false; let channels = match &mut self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { dec, up, order, adts_format } => { + Inner::Aac { dec, order, adts_format, .. } => { let mut out: Vec> = Vec::new(); let units = match adts_frames(packet) { Some(frames) => adts_units(frames, *adts_format)?, @@ -328,17 +331,11 @@ impl TrackDecoder { }; // A failed frame leaves the decoder as it was, so the frames before it in the // packet must too: their audio is replaced by the packet's concealment. - let before = (units.len() > 1).then(|| (dec.clone(), up.clone())); - let decoded = units.into_iter().try_for_each(|unit| { - let mut block = dec.decode(unit)?; - if let Some(up) = up.as_mut() { - block = up.process(block); - } - append(&mut out, block) - }); + let before = (units.len() > 1).then(|| dec.clone()); + let decoded = units.into_iter().try_for_each(|unit| append(&mut out, dec.decode(unit)?)); if let Err(e) = decoded { if let Some(before) = before { - (*dec, *up) = before; + *dec = before; } return Err(e); } @@ -440,8 +437,8 @@ impl TrackDecoder { None } #[cfg(feature = "aac-decode")] - Inner::Aac { up, .. } => { - let per_unit = aac::FRAME_LEN * if up.is_some() { 2 } else { 1 }; + Inner::Aac { sbr, .. } => { + let per_unit = aac::FRAME_LEN * if *sbr { 2 } else { 1 }; match adts_frames(data) { Some(f) => Some(f.iter().map(|(h, _)| usize::from(h.raw_data_blocks)).sum::().min(MAX_ADTS_FRAMES_PER_PACKET) * per_unit), // A packet lost whole: as long as the last one, in a stream of multi-frame @@ -488,7 +485,7 @@ impl TrackDecoder { pub fn frames_per_packet(&self) -> Option { match &self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { up, .. } => Some(aac::FRAME_LEN * if up.is_some() { 2 } else { 1 }), + Inner::Aac { sbr, .. } => Some(aac::FRAME_LEN * if *sbr { 2 } else { 1 }), #[cfg(feature = "alac-decode")] Inner::Alac { .. } => None, #[cfg(feature = "ac3-decode")] @@ -544,7 +541,7 @@ impl TrackDecoder { pub fn preroll(&self) -> usize { match &self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { up, .. } => 2 * aac::FRAME_LEN * if up.is_some() { 2 } else { 1 }, + Inner::Aac { sbr, .. } => 2 * aac::FRAME_LEN * if *sbr { 2 } else { 1 }, #[cfg(feature = "alac-decode")] Inner::Alac { .. } => 0, #[cfg(feature = "ac3-decode")] @@ -570,12 +567,7 @@ impl TrackDecoder { self.last_frames = 0; match &mut self.inner { #[cfg(feature = "aac-decode")] - Inner::Aac { dec, up, .. } => { - dec.reset(); - if let Some(up) = up { - up.reset(); - } - } + Inner::Aac { dec, .. } => dec.reset(), #[cfg(feature = "alac-decode")] Inner::Alac { .. } => {} #[cfg(feature = "ac3-decode")] diff --git a/crates/audio/tests/codecs/aac/he_aac_oracle.rs b/crates/audio/tests/codecs/aac/he_aac_oracle.rs new file mode 100644 index 000000000..e41493e16 --- /dev/null +++ b/crates/audio/tests/codecs/aac/he_aac_oracle.rs @@ -0,0 +1,165 @@ +//! HE-AAC (SBR) decoding against ffmpeg's decoder, on streams from ffmpeg's AudioToolbox encoder +//! (oracle only; skipped without ffmpeg or without `aac_at`, which only macOS builds have). + +use std::process::Command; + +use craft_audio::aac::{Decoder, split_adts}; + +use super::common::*; + +/// ffmpeg, if it has the AudioToolbox AAC encoder. +fn ffmpeg_with_aac_at() -> Option { + let ff = ffmpeg()?; + let out = Command::new(&ff).args(["-hide_banner", "-encoders"]).output().ok()?; + if !String::from_utf8_lossy(&out.stdout).contains("aac_at") { + eprintln!("SKIPPED: ffmpeg has no aac_at encoder"); + return None; + } + Some(ff) +} + +fn input(rate: u32, channels: usize, secs: f32) -> Vec> { + let n = (rate as f32 * secs) as usize; + (0..channels) + .map(|c| { + let a = chords(rate, n, 0.004 * c as f32); + let s = speech(rate, n, 11 + c as u64); + let z = noise(n, 0.05, 3 + c as u64); + let k = clicks(rate, n); + a.iter().zip(&s).zip(&z).zip(&k).map(|(((a, s), z), k)| 0.4 * a + 0.4 * s * if c == 1 { -0.7 } else { 1.0 } + z + 0.3 * k).collect() + }) + .collect() +} + +/// ffmpeg's decode of a stream of `channels` channels. ffmpeg decodes mono HE-AAC as stereo (it +/// allows for parametric stereo the stream may hold), with the same signal in both channels when +/// there is none: of those, the first is taken (a downmix would scale it by -3 dB). +fn ffmpeg_decode_as(ff: &std::path::Path, file: &std::path::Path, channels: usize) -> Vec> { + let mut planar = ffmpeg_decode_raw(ff, file, channels.max(2)); + planar.truncate(channels); + planar +} + +fn ffmpeg_decode_raw(ff: &std::path::Path, file: &std::path::Path, channels: usize) -> Vec> { + let out = Command::new(ff).args(["-v", "error", "-i"]).arg(file).args(["-ac", &channels.to_string(), "-f", "f32le", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr)); + let samples: Vec = out.stdout.as_chunks::<4>().0.iter().map(|&b| f32::from_le_bytes(b)).collect(); + (0..channels).map(|c| samples.iter().skip(c).step_by(channels).copied().collect()).collect() +} + +/// Our decode of an ADTS stream, SBR on. +fn decode_sbr(data: &[u8]) -> (Vec>, u32) { + let frames = split_adts(data).unwrap(); + let mut dec = Decoder::from_adts(&frames[0].0).unwrap(); + dec.set_sbr(true); + let mut out: Vec> = Vec::new(); + for (_, au) in frames { + let pcm = dec.decode(au).unwrap(); + out.resize(pcm.len(), Vec::new()); + for (o, p) in out.iter_mut().zip(pcm) { + o.extend(p); + } + } + (out, dec.sample_rate()) +} + +/// The lag of `b` behind `a` (within ±`range`) that correlates best. +fn best_lag(a: &[f32], b: &[f32], range: i64) -> i64 { + let n = a.len().min(b.len()) as i64; + let (from, to) = (range, n - range); + (-range..=range) + .max_by(|&x, &y| { + let c = |lag: i64| (from..to).step_by(3).map(|i| a[i as usize] as f64 * b[(i + lag) as usize] as f64).sum::(); + c(x).total_cmp(&c(y)) + }) + .unwrap() +} + +/// Energy in dB of `x[from..from + 2048]` within `lo..hi` Hz (Hann-windowed DFT). +fn band_db(x: &[f32], from: usize, rate: f32, lo: f32, hi: f32) -> f64 { + let n = 2048; + let seg = &x[from..from + n]; + let (b0, b1) = ((lo / rate * n as f32) as usize, (hi / rate * n as f32) as usize); + let mut e = 0f64; + for b in b0..b1.min(n / 2) { + let (mut re, mut im) = (0f64, 0f64); + for (i, &s) in seg.iter().enumerate() { + let w = 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos(); + let ph = 2.0 * std::f64::consts::PI * (b * i) as f64 / n as f64; + re += s as f64 * w * ph.cos(); + im += s as f64 * w * ph.sin(); + } + e += re * re + im * im; + } + 10.0 * (e + 1e-12).log10() +} + +struct Case { + name: &'static str, + rate: u32, + channels: usize, + bitrate: &'static str, +} + +/// HE-AAC streams of 1 to 6 channels at 22.05 to 48 kHz decode as ffmpeg decodes them: the same +/// timing, the low band to 55 dB and the rebuilt high band's level to 0.1 dB. +#[test] +fn matches_ffmpeg_he_aac_decoder() { + let Some(ff) = ffmpeg_with_aac_at() else { return }; + let cases = [ + Case { name: "he_stereo_44k_48k", rate: 44_100, channels: 2, bitrate: "48k" }, + Case { name: "he_mono_44k_32k", rate: 44_100, channels: 1, bitrate: "32k" }, + Case { name: "he_stereo_48k_64k", rate: 48_000, channels: 2, bitrate: "64k" }, + Case { name: "he_stereo_32k_32k", rate: 32_000, channels: 2, bitrate: "32k" }, + Case { name: "he_stereo_44k_24k", rate: 44_100, channels: 2, bitrate: "24k" }, + Case { name: "he_stereo_22k_24k", rate: 22_050, channels: 2, bitrate: "24k" }, + Case { name: "he_51_48k_160k", rate: 48_000, channels: 6, bitrate: "160k" }, + ]; + for case in &cases { + let path = fixtures().join(format!("{}.aac", case.name)); + ffmpeg_encode(&ff, &input(case.rate, case.channels, 3.0), case.rate, &["-c:a", "aac_at", "-profile:a", "4", "-b:a", case.bitrate], &path); + let data = std::fs::read(&path).unwrap(); + let refr = ffmpeg_decode_as(&ff, &path, case.channels); + let (ours, rate) = decode_sbr(&data); + // ffmpeg puts surround in WAV order, the decoder in AAC element order + let ours: Vec> = match (case.channels == 6).then(|| craft_audio::aac::wav_order(6)).flatten() { + Some(order) => order.iter().map(|&c| ours[c].clone()).collect(), + None => ours, + }; + assert_eq!(rate, case.rate, "{}", case.name); + assert_eq!(ours.len(), case.channels); + // the same delay as ffmpeg's decoder: 962 samples + assert_eq!(best_lag(&refr[0], &ours[0], 2100), 0, "{}", case.name); + let core_nyquist = case.rate as f32 / 4.0; + for c in 0..case.channels { + let n = refr[c].len().min(ours[c].len()) - 4096; + let a: Vec = refr[c][2048..n].to_vec(); + let b: Vec = ours[c][2048..n].to_vec(); + let full = snr(&a, &b); + let (la, lb) = (lowpass(&a, core_nyquist * 0.8, case.rate as f32), lowpass(&b, core_nyquist * 0.8, case.rate as f32)); + let low = snr(&la, &lb); + let mut diffs = Vec::new(); + for from in (4096..a.len() - 4096).step_by(4096) { + for (lo, hi) in + [(core_nyquist * 1.05, core_nyquist * 1.3), (core_nyquist * 1.3, core_nyquist * 1.7), (core_nyquist * 1.7, core_nyquist * 2.0)] + { + let (ea, eb) = (band_db(&a, from, case.rate as f32, lo, hi), band_db(&b, from, case.rate as f32, lo, hi)); + if ea > 0.0 { + diffs.push(eb - ea); + } + } + } + let mean = diffs.iter().sum::() / diffs.len().max(1) as f64; + let mad = diffs.iter().map(|d| d.abs()).sum::() / diffs.len().max(1) as f64; + println!( + "{} ch{c}: full SNR {full:.1} dB, low band SNR {low:.1} dB, high band level {mean:+.2} dB (mean |Δ| {mad:.2} dB over {})", + case.name, + diffs.len() + ); + // The low band differs by the core decoders' rounding; the high band's noise comes + // from the same table, so whole frames match too. + assert!(full > 45.0 && low > 55.0, "{} ch{c}: SNR {full:.1} / {low:.1} dB", case.name); + assert!(mad < 0.1, "{} ch{c}: high band off by {mad:.2} dB", case.name); + } + } +} diff --git a/crates/audio/tests/codecs/aac/mod.rs b/crates/audio/tests/codecs/aac/mod.rs index 45e627242..7d55a7364 100644 --- a/crates/audio/tests/codecs/aac/mod.rs +++ b/crates/audio/tests/codecs/aac/mod.rs @@ -3,4 +3,5 @@ mod common; mod decoder_oracle; mod encoder_oracle; +mod he_aac_oracle; mod robustness; From fa9ca366973c0883c9c5750e2204903534fc04ef Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 12 Nov 2026 20:28:37 +0100 Subject: [PATCH 009/118] craft-audio: SBR review fixes - A VARFIX pointer of 1 marks no transient envelope, like 0. - A start at QMF channel 32 is valid; kx is checked once the crossover is known. - The noise and harmonic gains take q / (1 + q) first, so two clamped levels no longer overflow to infinity and NaN. - A header that runs past its fill element is refused before it replaces the one in use. - A new program config layout drops the SBR decoders, whose history was the old order's. - The ffmpeg oracle requires a high band to compare on every channel but the 5.1 LFE. Refs #1121 Co-Authored-By: Claude Opus 5.5 --- crates/audio/src/aac/decoder.rs | 11 +++- crates/audio/src/aac/sbr/bitstream.rs | 6 ++- crates/audio/src/aac/sbr/freq.rs | 13 ++++- crates/audio/src/aac/sbr/hf.rs | 7 +-- crates/audio/src/aac/sbr/mod.rs | 51 +++++++++++++++---- .../audio/tests/codecs/aac/he_aac_oracle.rs | 10 ++-- 6 files changed, 78 insertions(+), 20 deletions(-) diff --git a/crates/audio/src/aac/decoder.rs b/crates/audio/src/aac/decoder.rs index 1d8713f54..ec5dae9fc 100644 --- a/crates/audio/src/aac/decoder.rs +++ b/crates/audio/src/aac/decoder.rs @@ -135,6 +135,10 @@ impl Decoder { } self.layout = layout; self.offsets = offsets; + // the SBR decoders' history belongs to the old element order: `apply_sbr` rebuilds them + if let Some(e) = self.sbr_out.as_mut() { + e.clear(); + } self.channels = vec![ChannelState { overlap: vec![0.0; 1024], prev_shape: WindowShape::Sine }; n]; } @@ -700,7 +704,7 @@ mod tests { /// its SBR fill element describes is there; with SBR off, the core alone (silence). #[test] fn sbr_fill_elements_rebuild_the_high_band() { - let payload = sbr::test_payload(44_100, |_| 40, 0).unwrap(); + let payload = sbr::test_payload(44_100, |_| 40, |_| 0).unwrap(); let mut bw = BitWriter::new(); bw.write_bits(ID_SCE, 3); bw.write_bits(0, 4); @@ -736,6 +740,11 @@ mod tests { assert_eq!(out.len(), 4 * 2048); let rms = (out[2048..].iter().map(|s| s * s).sum::() / (out.len() - 2048) as f32).sqrt(); assert!(rms > 1e-4, "{rms}"); + // a new layout drops the SBR decoders (their history is the old element order's) and + // keeps SBR on + dec.set_layout(vec![(ElementType::Sce, 0)]); + assert_eq!(dec.sbr_out.as_ref().map(Vec::len), Some(0)); + assert_eq!(dec.decode(&au).unwrap()[0].len(), 2048); } /// An access unit whose elements are all empty: `elements` are (id, tag), then ID_END. diff --git a/crates/audio/src/aac/sbr/bitstream.rs b/crates/audio/src/aac/sbr/bitstream.rs index 6233504fa..1d343bf7b 100644 --- a/crates/audio/src/aac/sbr/bitstream.rs +++ b/crates/audio/src/aac/sbr/bitstream.rs @@ -204,8 +204,9 @@ impl Grid { if t[0] < 0 || t[..=n].windows(2).any(|w| w[0] >= w[1]) { return Err(bad()); } + // l_A (ISO/IEC 14496-3 §4.6.18.3.3): VARFIX pointer 1, like 0, marks no envelope let la = match (pointer, class) { - (0, _) => None, + (0, _) | (1, VARFIX) => None, (p, FIXVAR | VARVAR) => Some((n + 1).checked_sub(p).ok_or_else(bad)?), (p, _) => Some(p - 1), }; @@ -468,6 +469,9 @@ mod tests { // VARFIX: leading border 1, relative 2, pointer 2: l_A = 1 let g = grid(&[(VARFIX as u32, 2), (1, 2), (1, 2), (0, 2), (2, 2), (1, 1), (0, 1)]).unwrap(); assert_eq!((g.num_env, &g.t_env[..3], g.la, g.t_noise), (2, &[1, 3, 16][..], Some(1), [1, 3, 16])); + // the same with pointer 1: no transient envelope, the noise floors still split at 3 + let g = grid(&[(VARFIX as u32, 2), (1, 2), (1, 2), (0, 2), (1, 2), (1, 1), (0, 1)]).unwrap(); + assert_eq!((g.la, g.t_noise), (None, [1, 3, 16])); // VARVAR: 1 .. 17 with one border from each end let g = grid(&[(VARVAR as u32, 2), (1, 2), (1, 2), (1, 2), (1, 2), (0, 2), (0, 2), (0, 2), (0, 1), (0, 1), (0, 1)]).unwrap(); assert_eq!((g.num_env, &g.t_env[..4], g.la), (3, &[1, 3, 15, 17][..], None)); diff --git a/crates/audio/src/aac/sbr/freq.rs b/crates/audio/src/aac/sbr/freq.rs index dc2809c10..d12fbdc02 100644 --- a/crates/audio/src/aac/sbr/freq.rs +++ b/crates/audio/src/aac/sbr/freq.rs @@ -75,8 +75,9 @@ fn start_stop(h: &Header, fs: u32) -> Option<(usize, usize)> { } } .min(64); - // the low band reaches only the analysis bank's 32 channels - (1..32).contains(&k0).then_some(())?; + // the low band reaches only the analysis bank's 32 channels (`kx` is checked against them + // once the crossover is known) + (1..=32).contains(&k0).then_some(())?; (k2 > k0).then_some((k0 as usize, k2 as usize)) } @@ -341,6 +342,14 @@ mod tests { assert!(valid > 10_000, "{valid}"); } + /// The highest start a header can give, 32 at 24 kHz (start 15), is valid: with stop 14 + /// (twice the start) and linear bands it covers channels 32 to 64. + #[test] + fn a_start_at_channel_32_is_accepted() { + let t = FreqTables::new(&header(15, 14, 0, 0, false, 2), 24_000).unwrap(); + assert_eq!((t.k0, t.kx, t.m), (32, 32, 32)); + } + /// A typical 44.1 kHz header: start channel 12 + offset, stop near 64, three noise bands. #[test] fn a_44k_header_has_the_expected_layout() { diff --git a/crates/audio/src/aac/sbr/hf.rs b/crates/audio/src/aac/sbr/hf.rs index 43ae572cf..1c2fd81ac 100644 --- a/crates/audio/src/aac/sbr/hf.rs +++ b/crates/audio/src/aac/sbr/hf.rs @@ -201,13 +201,14 @@ fn envelope_gains(f: &Frame, l: usize, sine_index: &[bool; 64], delta: bool) -> } let mut out = EnvelopeGains { gain: [0.0; 64], noise: [0.0; 64], sine: [0.0; 64] }; for c in 0..m { + // `q / (1 + q)` first: energy and noise ratio are each clamped, their product is not let (eo, ec, q) = (e_orig[c], e_curr[c], q[c]); - out.noise[c] = (eo * q / (1.0 + q)).sqrt(); + let share = q / (1.0 + q); + out.noise[c] = (eo * share).sqrt(); if sine_index[c] { out.sine[c] = (eo / (1.0 + q)).sqrt(); } - out.gain[c] = - if s_band[c] { (eo / (EPS + ec) * q / (1.0 + q)).sqrt() } else { (eo / ((EPS + ec) * (1.0 + if delta { q } else { 0.0 }))).sqrt() }; + out.gain[c] = if s_band[c] { (eo / (EPS + ec) * share).sqrt() } else { (eo / ((EPS + ec) * (1.0 + if delta { q } else { 0.0 }))).sqrt() }; } // limit per limiter band, then boost back the energy the limiter took let lim_gain = LIMITER_GAINS[usize::from(hdr.limiter_gains & 3)]; diff --git a/crates/audio/src/aac/sbr/mod.rs b/crates/audio/src/aac/sbr/mod.rs index 32a6c55b0..752cd03a0 100644 --- a/crates/audio/src/aac/sbr/mod.rs +++ b/crates/audio/src/aac/sbr/mod.rs @@ -285,6 +285,8 @@ impl Element { } if br.read_flag().ok()? { let h = Header::read(&mut br).ok()?; + // a header that runs past its fill element is damaged: keep the one in use + (br.position() <= p.end).then_some(())?; if self.header != Some(h) { if self.header.is_none_or(|o| !o.same_tables(&h)) { for c in &mut self.channels { @@ -377,13 +379,13 @@ pub(crate) const TEST_HEADER: Header = Header { /// An SBR single channel element for tests at output rate `rate`: [`TEST_HEADER`] (3 dB steps, /// start 5, stop 9, the default extra fields), then one FIXFIX envelope at high resolution with -/// band `k` at `env(k)` (1.5 dB steps, as a single FIXFIX envelope has) and every noise floor -/// value `noise`. +/// band `k` at `env(k)` (1.5 dB steps, as a single FIXFIX envelope has) and noise band `k` at +/// `noise(k)`. #[cfg(test)] -pub(crate) fn test_payload(rate: u32, env: impl Fn(usize) -> i32, noise: u32) -> Option> { +pub(crate) fn test_payload(rate: u32, env: impl Fn(usize) -> i32, noise: impl Fn(usize) -> i32) -> Option> { use craft_media::bitstream::BitWriter; let t = FreqTables::new(&TEST_HEADER, rate)?; - let (env, n_q): (Vec, usize) = ((0..t.n_high()).map(env).collect(), t.n_q()); + let (env, noise): (Vec, Vec) = ((0..t.n_high()).map(env).collect(), (0..t.n_q()).map(noise).collect()); let mut bw = BitWriter::new(); bw.write_bits(1, 1); // bs_header_flag for (v, n) in [(1, 1), (5, 4), (9, 4), (0, 3), (0, 2), (0, 1), (0, 1)] { @@ -394,7 +396,7 @@ pub(crate) fn test_payload(rate: u32, env: impl Fn(usize) -> i32, noise: u32) -> bw.write_bits(0, 2); // one envelope bw.write_bits(1, 1); // high resolution bw.write_bits(0, 2); // frequency-direction envelope and noise floor - for _ in 0..n_q { + for _ in &noise { bw.write_bits(0, 2); // no inverse filtering } bw.write_bits(env[0] as u32, 7); @@ -402,9 +404,9 @@ pub(crate) fn test_payload(rate: u32, env: impl Fn(usize) -> i32, noise: u32) -> let (code, len) = tables::F_ENV_1_5DB[(w[1] - w[0] + 60) as usize]; bw.write_bits(code, u32::from(len)); } - bw.write_bits(noise, 5); - for _ in 1..n_q { - let (code, len) = tables::F_ENV_3_0DB[31]; + bw.write_bits(noise[0] as u32, 5); + for w in noise.windows(2) { + let (code, len) = tables::F_ENV_3_0DB[(w[1] - w[0] + 31) as usize]; bw.write_bits(code, u32::from(len)); } bw.write_bits(0, 2); // no sinusoids, no extended data @@ -460,7 +462,7 @@ mod tests { fn the_high_band_follows_the_envelope() { let t = tables(); let mid = t.n_high() / 2; - let data = test_payload(RATE, |k| if k < mid { 30 } else { 42 }, 0).unwrap(); + let data = test_payload(RATE, |k| if k < mid { 30 } else { 42 }, |_| 0).unwrap(); let mut e = Element::new(1, RATE); let out = run(&mut e, &data, 6); let tail = &out[out.len() - 4096..]; @@ -481,7 +483,7 @@ mod tests { /// finite and of full length. #[test] fn damaged_sbr_data_is_survived() { - let good = test_payload(RATE, |k| 20 + k as i32, 3).unwrap(); + let good = test_payload(RATE, |k| 20 + k as i32, |_| 3).unwrap(); let mut seed = 0x1234_5678u32; let mut next = || { seed ^= seed << 13; @@ -510,4 +512,33 @@ mod tests { assert!(out.iter().all(|c| c.len() == 2048 && c.iter().all(|s| s.is_finite()))); } } + + /// The largest envelope (127 steps of 1.5 dB) with the largest noise floor (each band 31 + /// steps below the last) keeps the output finite: the gains are formed so that no + /// intermediate product of two clamped levels overflows. + #[test] + fn extreme_levels_stay_finite() { + let rate = + [22_050, 24_000, 32_000, 44_100, 48_000].into_iter().find(|&r| FreqTables::new(&TEST_HEADER, r).is_some_and(|t| t.n_q() >= 3)).unwrap(); + let data = test_payload(rate, |_| 127, |k| -31 * k as i32).unwrap(); + let mut e = Element::new(1, rate); + run(&mut e, &data, 3); + } + + /// A header that runs past the end of its fill element is not taken: the one in use stays, + /// and the frame is dropped. + #[test] + fn a_header_cut_short_is_not_taken() { + let data = test_payload(RATE, |_| 30, |_| 0).unwrap(); + let mut e = Element::new(1, RATE); + run(&mut e, &data, 1); + assert_eq!(e.header, Some(TEST_HEADER)); + // another start frequency (bits 2..6), in an element that ends 6 bits in + let mut cut = data.clone(); + cut[0] ^= 0x04; + assert!(e.read(Payload { data: &cut, start: 0, end: 6, crc: false }).is_none()); + assert_eq!(e.header, Some(TEST_HEADER)); + assert!(e.read(Payload { data: &cut, start: 0, end: 8 * cut.len(), crc: false }).is_some()); + assert_ne!(e.header, Some(TEST_HEADER)); + } } diff --git a/crates/audio/tests/codecs/aac/he_aac_oracle.rs b/crates/audio/tests/codecs/aac/he_aac_oracle.rs index e41493e16..0eeba69c8 100644 --- a/crates/audio/tests/codecs/aac/he_aac_oracle.rs +++ b/crates/audio/tests/codecs/aac/he_aac_oracle.rs @@ -149,8 +149,12 @@ fn matches_ffmpeg_he_aac_decoder() { } } } - let mean = diffs.iter().sum::() / diffs.len().max(1) as f64; - let mad = diffs.iter().map(|d| d.abs()).sum::() / diffs.len().max(1) as f64; + // the LFE (WAV channel 3 of 5.1) has no SBR, so no high band; every other channel must + // have one to compare + let lfe = case.channels == 6 && c == 3; + assert!(lfe || !diffs.is_empty(), "{} ch{c}: no high band to compare", case.name); + let mean = diffs.iter().sum::() / diffs.len() as f64; + let mad = diffs.iter().map(|d| d.abs()).sum::() / diffs.len() as f64; println!( "{} ch{c}: full SNR {full:.1} dB, low band SNR {low:.1} dB, high band level {mean:+.2} dB (mean |Δ| {mad:.2} dB over {})", case.name, @@ -159,7 +163,7 @@ fn matches_ffmpeg_he_aac_decoder() { // The low band differs by the core decoders' rounding; the high band's noise comes // from the same table, so whole frames match too. assert!(full > 45.0 && low > 55.0, "{} ch{c}: SNR {full:.1} / {low:.1} dB", case.name); - assert!(mad < 0.1, "{} ch{c}: high band off by {mad:.2} dB", case.name); + assert!(lfe || mad < 0.1, "{} ch{c}: high band off by {mad:.2} dB", case.name); } } } From 48f5283ec899ec7eab328bc55acc9e7e50b0ca71 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 12 Nov 2026 20:28:47 +0100 Subject: [PATCH 010/118] craft-audio: cite no section number for l_A Co-Authored-By: Claude Opus 5.5 --- crates/audio/src/aac/sbr/bitstream.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/audio/src/aac/sbr/bitstream.rs b/crates/audio/src/aac/sbr/bitstream.rs index 1d343bf7b..0e8e70c3a 100644 --- a/crates/audio/src/aac/sbr/bitstream.rs +++ b/crates/audio/src/aac/sbr/bitstream.rs @@ -204,7 +204,7 @@ impl Grid { if t[0] < 0 || t[..=n].windows(2).any(|w| w[0] >= w[1]) { return Err(bad()); } - // l_A (ISO/IEC 14496-3 §4.6.18.3.3): VARFIX pointer 1, like 0, marks no envelope + // l_A: in ISO/IEC 14496-3 a VARFIX pointer of 1, like 0, marks no transient envelope let la = match (pointer, class) { (0, _) | (1, VARFIX) => None, (p, FIXVAR | VARVAR) => Some((n + 1).checked_sub(p).ok_or_else(bad)?), From fe8c1f697a0fc04c8eabbffb526b5c9d51bfb9c6 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 12 Nov 2026 20:45:15 +0100 Subject: [PATCH 011/118] craft-audio: keep the SBR history when a new program config fails The SBR decoders now record the layout they were built for and are rebuilt when the decoder's layout differs, instead of being cleared in set_layout. An access unit whose new program config fails puts the layout back, and with it the SBR decoders still match. Refs #1121 Co-Authored-By: Claude Opus 5.5 --- crates/audio/src/aac/decoder.rs | 97 ++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/crates/audio/src/aac/decoder.rs b/crates/audio/src/aac/decoder.rs index ec5dae9fc..825ebb23a 100644 --- a/crates/audio/src/aac/decoder.rs +++ b/crates/audio/src/aac/decoder.rs @@ -85,6 +85,9 @@ pub struct Decoder { sbr_seen: bool, /// The SBR decoder of each layout entry, when output is at twice the core rate. sbr_out: Option>, + /// The layout `sbr_out` was built for. Kept apart from `layout`, so that an access unit whose + /// new program config fails (and whose layout is put back) leaves the SBR history alone. + sbr_layout: Vec<(ElementType, u8)>, } impl Decoder { @@ -121,6 +124,7 @@ impl Decoder { scratch: Scratch::new(), sbr_seen: false, sbr_out: None, + sbr_layout: Vec::new(), }; d.set_layout(layout); Ok(d) @@ -135,10 +139,6 @@ impl Decoder { } self.layout = layout; self.offsets = offsets; - // the SBR decoders' history belongs to the old element order: `apply_sbr` rebuilds them - if let Some(e) = self.sbr_out.as_mut() { - e.clear(); - } self.channels = vec![ChannelState { overlap: vec![0.0; 1024], prev_shape: WindowShape::Sine }; n]; } @@ -214,10 +214,11 @@ impl Decoder { let Some(elements) = self.sbr_out.as_mut() else { return pcm; }; - // a new layout (a program config element) starts the SBR decoders afresh - let fits = elements.len() == self.layout.len() && elements.iter().zip(&self.layout).all(|(e, l)| e.channels() == l.0.channels()); - if !fits { + // a new layout (a program config element) starts the SBR decoders afresh: their history + // belongs to the old element order, even where the channel counts match + if elements.len() != self.layout.len() || self.sbr_layout != self.layout { *elements = self.layout.iter().map(|l| sbr::Element::new(l.0.channels(), rate)).collect(); + self.sbr_layout.clone_from(&self.layout); } let mut out = Vec::with_capacity(pcm.len()); for (i, e) in elements.iter_mut().enumerate() { @@ -704,26 +705,7 @@ mod tests { /// its SBR fill element describes is there; with SBR off, the core alone (silence). #[test] fn sbr_fill_elements_rebuild_the_high_band() { - let payload = sbr::test_payload(44_100, |_| 40, |_| 0).unwrap(); - let mut bw = BitWriter::new(); - bw.write_bits(ID_SCE, 3); - bw.write_bits(0, 4); - bw.write_bits(0, 8 + 11 + 3); // global_gain, ics_info (max_sfb 0), no pulse/TNS/gain control - bw.write_bits(ID_FIL, 3); - let count = (4 + 8 * payload.len()).div_ceil(8); - if count < 15 { - bw.write_bits(count as u32, 4); - } else { - bw.write_bits(15, 4); - bw.write_bits((count - 14) as u32, 8); - } - bw.write_bits(EXT_SBR_DATA, 4); - for &b in &payload { - bw.write_bits(u32::from(b), 8); - } - bw.write_bits(0, 4); - bw.write_bits(ID_END, 3); - let au = bw.finish(); + let au = sbr_au(0); // AAC-LC, 22.05 kHz, mono let asc = AudioSpecificConfig::lc(7, 1); let mut dec = Decoder::from_config(asc.clone()).unwrap(); @@ -740,11 +722,62 @@ mod tests { assert_eq!(out.len(), 4 * 2048); let rms = (out[2048..].iter().map(|s| s * s).sum::() / (out.len() - 2048) as f32).sqrt(); assert!(rms > 1e-4, "{rms}"); - // a new layout drops the SBR decoders (their history is the old element order's) and - // keeps SBR on - dec.set_layout(vec![(ElementType::Sce, 0)]); - assert_eq!(dec.sbr_out.as_ref().map(Vec::len), Some(0)); - assert_eq!(dec.decode(&au).unwrap()[0].len(), 2048); + } + + /// A program config that changes the layout restarts the SBR decoders, even where the + /// channel counts match, and one in an access unit that fails leaves their history alone: the + /// access units after it decode as if it had not been there. + #[test] + fn a_new_layout_restarts_sbr_and_a_failed_one_keeps_it() { + let au = sbr_au(0); + let one = ProgramConfig { front: vec![(false, 0)], ..Default::default() }; + let six = ProgramConfig { front: vec![(false, 0), (true, 0)], back: vec![(true, 1)], lfe: vec![0], ..Default::default() }; + let decoder = || { + let mut d = Decoder::from_config(AudioSpecificConfig { pce: Some(one.clone()), ..AudioSpecificConfig::lc(7, 0) }).unwrap(); + d.set_sbr(true); + d + }; + let (mut kept, mut failed) = (decoder(), decoder()); + for _ in 0..3 { + assert_eq!(kept.decode(&au).unwrap(), failed.decode(&au).unwrap()); + } + // the new layout's elements are missing from this access unit + assert!(failed.decode(&silent_au(Some(&six), &[(ID_SCE, 0)])).is_err()); + for _ in 0..2 { + assert_eq!(kept.decode(&au).unwrap(), failed.decode(&au).unwrap()); + } + // the same channel count under another tag is a new layout: the SBR decoder starts over, + // as in a decoder that had that layout from the start + let tag1 = ProgramConfig { front: vec![(false, 1)], ..Default::default() }; + let mut fresh = Decoder::from_config(AudioSpecificConfig { pce: Some(tag1.clone()), ..AudioSpecificConfig::lc(7, 0) }).unwrap(); + fresh.set_sbr(true); + kept.set_layout(tag1.layout()); + assert_eq!(kept.decode(&sbr_au(1)).unwrap(), fresh.decode(&sbr_au(1)).unwrap()); + } + + /// One access unit of SCE `tag` with an SBR fill element of [`sbr::test_payload`] (44.1 kHz + /// output). + fn sbr_au(tag: u32) -> Vec { + let payload = sbr::test_payload(44_100, |_| 40, |_| 0).unwrap(); + let mut bw = BitWriter::new(); + bw.write_bits(ID_SCE, 3); + bw.write_bits(tag, 4); + bw.write_bits(0, 8 + 11 + 3); // global_gain, ics_info (max_sfb 0), no pulse/TNS/gain control + bw.write_bits(ID_FIL, 3); + let count = (4 + 8 * payload.len()).div_ceil(8); + if count < 15 { + bw.write_bits(count as u32, 4); + } else { + bw.write_bits(15, 4); + bw.write_bits((count - 14) as u32, 8); + } + bw.write_bits(EXT_SBR_DATA, 4); + for &b in &payload { + bw.write_bits(u32::from(b), 8); + } + bw.write_bits(0, 4); + bw.write_bits(ID_END, 3); + bw.finish() } /// An access unit whose elements are all empty: `elements` are (id, tag), then ID_END. From 7af2f8e868e7e51c70d26fbe4459bb67076a8789 Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 13 Nov 2026 07:34:27 +0100 Subject: [PATCH 012/118] craft-media: AVI muxer (AviWriter) Write AVI files: hdrl with avih and one strl (strh, strf, strn, indx) per stream, odml/dmlh, an interleaved movi and idx1. Past the RIFF limit (default 1 GiB) the file continues in OpenDML RIFF AVIX extensions with ix## standard indexes and an indx super index per stream. - VideoSpec::for_codec builds the stream from CodecParams; MPEG-4 Part 2 is tagged FMP4. - Audio is timed by bytes (PCM-style) or one packet per chunk. - Bad options and chunks are errors; finish() closes a readable file after a failed write. Tests read the files back through this crate's demuxer by every index, and through ffmpeg (decode identical to the sources, seeks through the index, ffprobe fields), in one RIFF and across AVIX extensions. The MPEG-4 encoder tests play its packets from an AVI file. --- crates/media/Cargo.toml | 6 +- crates/media/README.md | 53 +- crates/media/src/avi/mod.rs | 9 +- crates/media/src/avi/mux.rs | 617 ++++++++++++++++++++++++ crates/media/src/avi/mux_tests.rs | 205 ++++++++ crates/media/tests/oracle_avi_mux.rs | 156 ++++++ crates/video/Cargo.toml | 2 + crates/video/tests/mpeg4_encode/main.rs | 34 ++ 8 files changed, 1071 insertions(+), 11 deletions(-) create mode 100644 crates/media/src/avi/mux.rs create mode 100644 crates/media/src/avi/mux_tests.rs create mode 100644 crates/media/tests/oracle_avi_mux.rs diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index cdbf28349..4943b1771 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -18,7 +18,7 @@ default = ["isobmff"] bitstream = [] isobmff = ["bitstream", "dep:thiserror"] matroska = [] -# AVI (RIFF) and OpenDML demuxer (`craft_media::avi`). +# AVI (RIFF) and OpenDML demuxer and muxer (`craft_media::avi`). avi = [] # MXF demuxer and writer (`craft_media::mxf`); the writer formats dates with chrono. mxf = ["dep:chrono"] @@ -51,6 +51,10 @@ required-features = ["matroska"] name = "oracle_avi" required-features = ["avi"] +[[test]] +name = "oracle_avi_mux" +required-features = ["avi"] + [[test]] name = "oracle_demux" required-features = ["isobmff"] diff --git a/crates/media/README.md b/crates/media/README.md index 387ab8cdd..8eca6ecb5 100644 --- a/crates/media/README.md +++ b/crates/media/README.md @@ -1,8 +1,8 @@ # craft-media Media containers and bit-level I/O for the Craft apps: an MSB-first bit reader and writer, -clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) and MXF demuxers and muxers, and -Ogg, AVI and MPEG-2 Systems (transport and program stream) demuxers, written from the public specifications. No `unsafe`, no panics on malformed +clean-room ISO BMFF (MP4), QuickTime (MOV), Matroska (MKV, WebM), MXF and AVI demuxers and muxers, and +Ogg and MPEG-2 Systems (transport and program stream) demuxers, written from the public specifications. No `unsafe`, no panics on malformed input, builds for `wasm32-unknown-unknown`. Dependencies: `thiserror` (for `isobmff`), `chrono` (for `mxf`), `serde` (optional). @@ -44,8 +44,7 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = " - In scope: bit-level reading and writing, and container demuxing and muxing (boxes, sample tables, sample entries and codec configuration records) for audio and video tracks. - Out of scope: codecs. Audio codecs are in `craft-audio`; video codecs (H.264, HEVC, ProRes, - DNx, VP8, VP9, AV1) are in `craft-video`. Containers only FilmCraft reads (MXF, - MPEG-TS/PS, AVI) are not here yet. + DNx, VP8, VP9, AV1, MPEG-4 Part 2) are in `craft-video`. - Anything both the audio and the video codec crates need belongs here, so the two never depend on each other. @@ -448,10 +447,10 @@ No muxer. ## `avi` -A clean-room AVI demuxer from FilmCraft's `filmcraft-avi` (storytold/filmcraft@4bec22b), written -from Microsoft's published AVI RIFF file reference and the OpenDML AVI File Format Extensions 1.02. -No FFmpeg, libavformat or other AVI implementation was read; ffmpeg and ffprobe are test oracles -only. No muxer. +A clean-room AVI demuxer from FilmCraft's `filmcraft-avi` (storytold/filmcraft@4bec22b), and a +muxer, both written from Microsoft's published AVI RIFF file reference and the OpenDML AVI File +Format Extensions 1.02. No FFmpeg, libavformat or other AVI implementation was read; ffmpeg and +ffprobe are test oracles only. ```rust use craft_media::avi::{open, StreamKind}; @@ -496,6 +495,44 @@ Tests: hand-built files for every index path, truncation, every single-byte muta multi-byte damage (never a panic). `tests/oracle_avi.rs` checks ffmpeg-made files (Motion JPEG, H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb` checks a 1.16 GB OpenDML file with `AVIX` extensions. + +### Muxer + +```rust +use craft_media::avi::{AudioSpec, AviWriter, MuxOptions, StreamSpec, VideoSpec}; + +let video = VideoSpec::new(*b"XVID", 640, 360, 25, 1); // or VideoSpec::for_codec(&encoder.codec_params(), 25, 1) +let audio = AudioSpec::pcm(48_000, 2, 16); +let mut w = AviWriter::new(file, vec![StreamSpec::Video(video), StreamSpec::Audio(audio)], MuxOptions::default())?; +w.write_chunk(0, &frame, keyframe)?; // chunks go in the order written: the caller interleaves +w.write_chunk(1, &pcm, true)?; +let file = w.finish()?; // Write + Seek: sizes and the header list are rewritten at the end +``` + +- Layout: `RIFF AVI ` with `hdrl` (`avih`; per stream `strh`, `strf`, `strn` and an OpenDML + `indx`; `odml`/`dmlh`), `LIST movi`, `idx1`. Chunks are `NNdc` (video) and `NNwb` (audio) with + decimal stream numbers, padded to even sizes. Empty video chunks (repeat the previous picture) + are allowed. +- Large files: when a RIFF would pass `MuxOptions::riff_limit` (1 GiB by default, 16 KiB to + 2 GiB) the file continues in a `RIFF AVIX`. Every RIFF ends its `movi` with an `ix##` standard + index per stream, listed in that stream's `indx`; `idx1` covers the first RIFF only, and + `avih` counts the first RIFF's frames while `dmlh` and `strh` count all of them, as OpenDML + asks. The `indx` has room for `MuxOptions::max_riffs` (256) RIFFs, reserved in the header. +- Streams: video with any `BITMAPINFOHEADER` (`VideoSpec::for_codec` picks the FourCC of an + encoder's `CodecParams`: `FMP4`, `H264`, `HEVC`, `MJPG`, `MPG2`, `VP80`, `VP90`, `AV01`; `FMP4` and + not `XVID` for MPEG-4 Part 2, which decoders take to name the encoder), and audio + with any `WAVEFORMATEX`, timed by its bytes (`AudioTiming::Bytes`: PCM and other fixed-size + sample frames) or a packet a chunk (`AudioTiming::Packets`: MP3, AAC, AC-3). Up to 100 streams. +- Errors, not panics: no streams, a zero rate, a chunk that does not fit in a RIFF, more RIFFs + than `max_riffs` (what was written still closes into a readable file). + +Tests: `mux_tests.rs` reads files back through the demuxer on every index path (OpenDML, `idx1` +with the `AVIX` extensions scanned, a scan), with odd and empty chunks, several `AVIX` +extensions, a file starting inside its output, packet-timed audio and the error cases. +`tests/oracle_avi_mux.rs` muxes an ffmpeg-made MPEG-4 Part 2 stream and PCM, in one RIFF and +across `AVIX` extensions: ffmpeg decodes the AVI to exactly the pictures and samples of the +sources, seeking through the index lands on the right picture, and ffprobe reads the codec, +FourCC, size, frame rate, packet count, audio format and duration. ## `mpegts` A clean-room MPEG-2 Systems demuxer from FilmCraft's `filmcraft-mpegts` diff --git a/crates/media/src/avi/mod.rs b/crates/media/src/avi/mod.rs index 592139cef..eaaa1bbe2 100644 --- a/crates/media/src/avi/mod.rs +++ b/crates/media/src/avi/mod.rs @@ -1,5 +1,5 @@ -//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the -//! OpenDML AVI File Format Extensions 1.02. +//! Clean-room AVI demuxer and muxer ([`AviWriter`]), written from Microsoft's published AVI RIFF +//! file reference and the OpenDML AVI File Format Extensions 1.02. //! //! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` / //! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index @@ -18,9 +18,14 @@ #![warn(missing_docs)] +mod mux; +#[cfg(test)] +mod mux_tests; #[cfg(test)] mod tests; +pub use mux::{AudioSpec, AudioTiming, AviWriter, MuxOptions, StreamSpec, VideoSpec}; + pub use crate::source::{ByteSource, ReadSeekSource}; use std::collections::HashSet; diff --git a/crates/media/src/avi/mux.rs b/crates/media/src/avi/mux.rs new file mode 100644 index 000000000..a6e03e174 --- /dev/null +++ b/crates/media/src/avi/mux.rs @@ -0,0 +1,617 @@ +//! Clean-room AVI muxer, written from Microsoft's published AVI RIFF file reference and the +//! OpenDML AVI File Format Extensions 1.02. +//! +//! [`AviWriter`] writes one `RIFF AVI ` with the `hdrl` header list, a `movi` list of chunks in +//! the order they are given, and the AVI 1.0 `idx1` index. A file that grows past +//! [`MuxOptions::riff_limit`] (1 GiB) continues in `RIFF AVIX` extensions. Every RIFF ends its +//! `movi` list with an OpenDML standard index (`ix##`) per stream, and each stream's `strl` +//! holds a super index (`indx`) of them, so readers find the chunks past the first RIFF; `idx1` +//! covers the first RIFF only, as OpenDML asks. +//! +//! The header list is written at the start with room reserved for the super indexes and +//! rewritten with the final counts by [`AviWriter::finish`]: the output must be `Write + Seek`. + +use std::io::{Seek, SeekFrom, Write}; + +use super::{BitmapInfo, Error, Result, WaveFormat}; +use crate::{CodecId, CodecParams}; + +/// `AVIF_HASINDEX`: the file has an `idx1`. +const AVIF_HASINDEX: u32 = 0x10; +/// `AVIF_ISINTERLEAVED`: the chunks of the streams are interleaved. +const AVIF_ISINTERLEAVED: u32 = 0x100; +/// `AVIIF_KEYFRAME` in an `idx1` entry. +const AVIIF_KEYFRAME: u32 = 0x10; +/// Bit 31 of a standard index entry's size: not a key frame. +const NOT_KEY: u32 = 0x8000_0000; +/// `dmlh` is 248 bytes: `dwTotalFrames` and reserved space. +const DMLH_SIZE: usize = 248; +/// Chunk ids carry a two-digit decimal stream number. +const MAX_STREAMS: usize = 100; +/// The smallest RIFF size the writer accepts. +const MIN_RIFF_LIMIT: u64 = 16 << 10; +/// The largest: RIFF and chunk sizes are 32-bit, and OpenDML entries use bit 31 as a flag. +const MAX_RIFF_LIMIT: u64 = 1 << 31; + +/// How the chunks of an audio stream measure time (`strh` `dwScale`, `dwRate`, `dwSampleSize`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AudioTiming { + /// Chunks are runs of fixed-size sample frames of `block_align` bytes (PCM, A-law, constant + /// bitrate MP3 written as a byte stream): the stream is timed by its bytes. + Bytes, + /// Each chunk is one packet of `samples_per_packet` sample frames (1152 for MPEG audio Layer + /// II/III at 32 kHz and above, 1024 for AAC, 1536 for AC-3). + Packets { + /// Sample frames in a packet. + samples_per_packet: u32, + }, +} + +/// A video stream for [`AviWriter`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VideoSpec { + /// The `BITMAPINFOHEADER` written as `strf`. `compression` is the FourCC (`XVID`, `MJPG`, + /// `H264`…), and `extra` follows the 40-byte header. + pub format: BitmapInfo, + /// `fccHandler`; the compression FourCC when `None`. + pub handler: Option<[u8; 4]>, + /// Frames per second is `rate / scale`. + pub rate: u32, + /// See `rate`. + pub scale: u32, + /// `strn`. + pub name: Option, +} + +impl VideoSpec { + /// A compressed stream of `fourcc` pictures, 24 bits per pixel in the header, at `rate / scale` + /// frames per second. + pub fn new(fourcc: [u8; 4], width: u32, height: u32, rate: u32, scale: u32) -> VideoSpec { + VideoSpec { + format: BitmapInfo { + width: i32::try_from(width).unwrap_or(i32::MAX), + height: i32::try_from(height).unwrap_or(i32::MAX), + bit_count: 24, + compression: fourcc, + size_image: width.saturating_mul(height).saturating_mul(3), + extra: Vec::new(), + }, + handler: None, + rate, + scale, + name: None, + } + } + + /// A stream for an encoder's track description: the usual AVI FourCC of its codec (`FMP4` for + /// MPEG-4 Part 2, `H264`, `HEVC`, `MJPG`, `MPG2`, `VP80`, `VP90`, `AV01`), its picture size + /// and its extradata after the header. MPEG-4 Part 2 gets `FMP4` rather than `XVID` or `DIVX` + /// because decoders read those as naming the encoder: ffmpeg, for one, decodes an `XVID` + /// stream with Xvid's own IDCT. Set `format.compression` for a player that wants another. `None` for a codec without a FourCC here or a + /// description without a size. H.264 and HEVC chunks must then be Annex B with an Annex B or + /// empty extradata, as AVI readers expect. + pub fn for_codec(params: &CodecParams, rate: u32, scale: u32) -> Option { + let fourcc = match params.codec { + CodecId::Mpeg4Video => *b"FMP4", + CodecId::H264 => *b"H264", + CodecId::Hevc => *b"HEVC", + CodecId::Mjpeg => *b"MJPG", + CodecId::Mpeg2Video => *b"MPG2", + CodecId::Vp8 => *b"VP80", + CodecId::Vp9 => *b"VP90", + CodecId::Av1 => *b"AV01", + _ => return None, + }; + let mut v = VideoSpec::new(fourcc, params.width?, params.height?, rate, scale); + v.format.extra = params.extradata.clone(); + Some(v) + } +} + +/// An audio stream for [`AviWriter`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioSpec { + /// The `WAVEFORMATEX` written as `strf`. + pub format: WaveFormat, + /// How its chunks measure time. + pub timing: AudioTiming, + /// `strn`. + pub name: Option, +} + +impl AudioSpec { + /// Interleaved little-endian integer PCM (`bits` 8, 16, 24 or 32), timed by its bytes. + pub fn pcm(sample_rate: u32, channels: u16, bits: u16) -> AudioSpec { + let block_align = channels.saturating_mul(bits.div_ceil(8)); + AudioSpec { + format: WaveFormat { + format_tag: 1, + channels, + sample_rate, + avg_bytes_per_sec: sample_rate.saturating_mul(u32::from(block_align)), + block_align, + bits_per_sample: bits, + extra: Vec::new(), + }, + timing: AudioTiming::Bytes, + name: None, + } + } +} + +/// One stream of an AVI file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StreamSpec { + /// `vids`. + Video(VideoSpec), + /// `auds`. + Audio(AudioSpec), +} + +/// Options for [`AviWriter`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MuxOptions { + /// Largest size of one RIFF (the first, then each `AVIX` extension), 16 KiB to 2 GiB. + /// 1 GiB by default, which OpenDML recommends for the first RIFF so AVI 1.0 readers can use it. + pub riff_limit: u64, + /// RIFFs the super indexes have room for (each takes 16 bytes a stream in the header). 256 by + /// default: 256 GiB at the default `riff_limit`. + pub max_riffs: u32, +} + +impl Default for MuxOptions { + fn default() -> Self { + MuxOptions { riff_limit: 1 << 30, max_riffs: 256 } + } +} + +/// A chunk's entry in the standard index of its RIFF. +#[derive(Clone, Copy, Debug)] +struct Entry { + /// Offset of the data from the `movi` list's type id (the index's base). + offset: u32, + size: u32, + key: bool, +} + +/// One `ix##` written: its position, its size and the stream time it covers. +#[derive(Clone, Copy, Debug)] +struct SuperEntry { + at: u64, + size: u32, + duration: u32, +} + +/// The RIFF being written. +#[derive(Clone, Copy, Debug)] +struct Riff { + /// Position of `RIFF`. + start: u64, + /// Position of the `movi` list's `LIST`. + movi: u64, +} + +#[derive(Debug)] +struct StreamState { + spec: StreamSpec, + /// `00dc`, `01wb`… + id: [u8; 4], + chunks: u64, + bytes: u64, + largest: u32, + /// The chunks of the current RIFF. + entries: Vec, + /// Stream time of the current RIFF's chunks. + duration: u64, + supers: Vec, +} + +impl StreamState { + /// Stream time (`dwScale / dwRate` units) of a chunk of `len` bytes. + fn units(&self, len: u64) -> u64 { + match &self.spec { + StreamSpec::Audio(a) if a.timing == AudioTiming::Bytes => len / u64::from(a.format.block_align.max(1)), + _ => 1, + } + } + + /// `(dwScale, dwRate, dwSampleSize)`. + fn time_base(&self) -> (u32, u32, u32) { + match &self.spec { + StreamSpec::Video(v) => (v.scale, v.rate, 0), + StreamSpec::Audio(a) => match a.timing { + AudioTiming::Bytes => (u32::from(a.format.block_align), a.format.avg_bytes_per_sec, u32::from(a.format.block_align)), + AudioTiming::Packets { samples_per_packet } => (samples_per_packet, a.format.sample_rate, 0), + }, + } + } + + /// The stream's length in its own units. + fn length(&self) -> u64 { + match &self.spec { + StreamSpec::Audio(a) if a.timing == AudioTiming::Bytes => self.bytes / u64::from(a.format.block_align.max(1)), + _ => self.chunks, + } + } + + /// The stream's duration in seconds. + fn seconds(&self) -> f64 { + let (scale, rate, _) = self.time_base(); + self.length() as f64 * f64::from(scale) / f64::from(rate.max(1)) + } +} + +fn le16(v: &mut Vec, x: u16) { + v.extend_from_slice(&x.to_le_bytes()); +} + +fn le32(v: &mut Vec, x: u32) { + v.extend_from_slice(&x.to_le_bytes()); +} + +fn sat32(x: u64) -> u32 { + u32::try_from(x).unwrap_or(u32::MAX) +} + +/// A chunk: id, size, data, a pad byte to an even size. +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut v = Vec::with_capacity(data.len() + 9); + v.extend_from_slice(id); + le32(&mut v, sat32(data.len() as u64)); + v.extend_from_slice(data); + if data.len() % 2 == 1 { + v.push(0); + } + v +} + +fn list(ltype: &[u8; 4], body: &[u8]) -> Vec { + chunk(b"LIST", &[ltype.as_slice(), body].concat()) +} + +fn invalid(msg: impl Into) -> Error { + Error::Invalid(msg.into()) +} + +/// The `hdrl` list. Its length depends only on the stream descriptions and `max_riffs`, so the +/// final one ([`AviWriter::finish`]) replaces the first in place. +fn hdrl(streams: &[StreamState], main: Option, max_riffs: u32, first_frames: u32) -> Vec { + let video = main.and_then(|i| streams.get(i)).and_then(|s| match &s.spec { + StreamSpec::Video(v) => Some((v, s)), + StreamSpec::Audio(_) => None, + }); + let seconds = streams.iter().map(StreamState::seconds).fold(0.0, f64::max); + let total: u64 = streams.iter().map(|s| s.bytes).sum(); + let largest = streams.iter().map(|s| s.largest).max().unwrap_or(0); + + let mut avih = Vec::with_capacity(56); + le32(&mut avih, video.map_or(0, |(v, _)| (1e6 * f64::from(v.scale) / f64::from(v.rate)).round() as u32)); + le32(&mut avih, if seconds > 0.0 { (total as f64 / seconds).ceil() as u32 } else { 0 }); + le32(&mut avih, 0); // dwPaddingGranularity + le32(&mut avih, AVIF_HASINDEX | AVIF_ISINTERLEAVED); + le32(&mut avih, first_frames); + le32(&mut avih, 0); // dwInitialFrames + le32(&mut avih, streams.len() as u32); + le32(&mut avih, largest.saturating_add(8)); + le32(&mut avih, video.map_or(0, |(v, _)| v.format.width.unsigned_abs())); + le32(&mut avih, video.map_or(0, |(v, _)| v.format.height.unsigned_abs())); + avih.extend_from_slice(&[0; 16]); + + let mut body = chunk(b"avih", &avih); + for s in streams { + let (scale, rate, sample_size) = s.time_base(); + let mut strh = Vec::with_capacity(56); + let (fcc, handler, frame) = match &s.spec { + StreamSpec::Video(v) => (*b"vids", v.handler.unwrap_or(v.format.compression), (v.format.width, v.format.height.abs())), + StreamSpec::Audio(_) => (*b"auds", [0; 4], (0, 0)), + }; + strh.extend_from_slice(&fcc); + strh.extend_from_slice(&handler); + le32(&mut strh, 0); // dwFlags + le16(&mut strh, 0); // wPriority + le16(&mut strh, 0); // wLanguage + le32(&mut strh, 0); // dwInitialFrames + le32(&mut strh, scale); + le32(&mut strh, rate); + le32(&mut strh, 0); // dwStart + le32(&mut strh, sat32(s.length())); + le32(&mut strh, s.largest); + le32(&mut strh, u32::MAX); // dwQuality: default + le32(&mut strh, sample_size); + for v in [0, 0, frame.0, frame.1] { + le16(&mut strh, i16::try_from(v).unwrap_or(i16::MAX) as u16); + } + + let mut strf = Vec::new(); + match &s.spec { + StreamSpec::Video(v) => { + let f = &v.format; + le32(&mut strf, sat32(40 + f.extra.len() as u64)); + le32(&mut strf, f.width as u32); + le32(&mut strf, f.height as u32); + le16(&mut strf, 1); // biPlanes + le16(&mut strf, f.bit_count); + strf.extend_from_slice(&f.compression); + le32(&mut strf, f.size_image); + strf.extend_from_slice(&[0; 16]); // pels per metre, colours used and important + strf.extend_from_slice(&f.extra); + } + StreamSpec::Audio(a) => { + let f = &a.format; + le16(&mut strf, f.format_tag); + le16(&mut strf, f.channels); + le32(&mut strf, f.sample_rate); + le32(&mut strf, f.avg_bytes_per_sec); + le16(&mut strf, f.block_align); + le16(&mut strf, f.bits_per_sample); + le16(&mut strf, u16::try_from(f.extra.len()).unwrap_or(u16::MAX)); + strf.extend_from_slice(&f.extra[..f.extra.len().min(usize::from(u16::MAX))]); + } + } + + // indx: AVI_INDEX_OF_INDEXES, room for `max_riffs` entries + let mut indx = Vec::with_capacity(24 + 16 * max_riffs as usize); + le16(&mut indx, 4); // wLongsPerEntry + indx.push(0); // bIndexSubType + indx.push(0); // bIndexType: AVI_INDEX_OF_INDEXES + le32(&mut indx, s.supers.len() as u32); + indx.extend_from_slice(&s.id); + indx.extend_from_slice(&[0; 12]); + for e in &s.supers { + indx.extend_from_slice(&e.at.to_le_bytes()); + le32(&mut indx, e.size); + le32(&mut indx, e.duration); + } + indx.resize(24 + 16 * max_riffs as usize, 0); + + let mut strl = chunk(b"strh", &strh); + strl.extend(chunk(b"strf", &strf)); + let name = match &s.spec { + StreamSpec::Video(v) => v.name.as_deref(), + StreamSpec::Audio(a) => a.name.as_deref(), + }; + if let Some(n) = name { + strl.extend(chunk(b"strn", &[n.as_bytes(), &[0]].concat())); + } + strl.extend(chunk(b"indx", &indx)); + body.extend(list(b"strl", &strl)); + } + let mut dmlh = vec![0u8; DMLH_SIZE]; + let frames = video.map_or(0, |(_, s)| sat32(s.chunks)); + dmlh[..4].copy_from_slice(&frames.to_le_bytes()); + body.extend(list(b"odml", &chunk(b"dmlh", &dmlh))); + list(b"hdrl", &body) +} + +/// Writes an AVI file chunk by chunk; see the [module documentation](self). +#[derive(Debug)] +pub struct AviWriter { + w: W, + /// Where the file starts in `w`. + base: u64, + opts: MuxOptions, + streams: Vec, + /// The stream whose frame period and size the main header gives (the first video stream). + main: Option, + /// The next byte to write, from the start of the file. + pos: u64, + /// Bytes of the `hdrl` list, fixed so it can be rewritten in place. + hdrl_len: usize, + riff: Riff, + /// RIFFs started so far. + riffs: u32, + /// `idx1` of the first RIFF (16 bytes an entry), while it is open. + idx1: Vec<[u8; 16]>, + /// Video frames in the first RIFF (`avih` `dwTotalFrames`). + first_frames: u32, +} + +impl AviWriter { + /// Start a file with `streams` (1 to 100) at the current position of `w`. + pub fn new(mut w: W, streams: Vec, opts: MuxOptions) -> Result { + if streams.is_empty() || streams.len() > MAX_STREAMS { + return Err(invalid(format!("{} streams (1 to {MAX_STREAMS})", streams.len()))); + } + if !(MIN_RIFF_LIMIT..=MAX_RIFF_LIMIT).contains(&opts.riff_limit) { + return Err(invalid(format!("riff_limit {} is outside {MIN_RIFF_LIMIT}..={MAX_RIFF_LIMIT}", opts.riff_limit))); + } + if opts.max_riffs == 0 || opts.max_riffs > 1 << 16 { + return Err(invalid("max_riffs must be 1 to 65536")); + } + for (i, s) in streams.iter().enumerate() { + match s { + StreamSpec::Video(v) if v.rate == 0 || v.scale == 0 => return Err(invalid(format!("stream {i}: zero frame rate"))), + StreamSpec::Audio(a) if a.format.sample_rate == 0 => return Err(invalid(format!("stream {i}: zero sample rate"))), + StreamSpec::Audio(a) if a.timing == AudioTiming::Bytes && (a.format.block_align == 0 || a.format.avg_bytes_per_sec == 0) => { + return Err(invalid(format!("stream {i}: byte-timed audio needs a block align and a byte rate"))); + } + StreamSpec::Audio(AudioSpec { timing: AudioTiming::Packets { samples_per_packet: 0 }, .. }) => { + return Err(invalid(format!("stream {i}: zero samples a packet"))); + } + _ => {} + } + } + let main = streams.iter().position(|s| matches!(s, StreamSpec::Video(_))); + let streams: Vec = streams + .into_iter() + .enumerate() + .map(|(i, spec)| { + let kind = if matches!(spec, StreamSpec::Video(_)) { b"dc" } else { b"wb" }; + StreamState { + spec, + id: [b'0' + (i / 10) as u8, b'0' + (i % 10) as u8, kind[0], kind[1]], + chunks: 0, + bytes: 0, + largest: 0, + entries: Vec::new(), + duration: 0, + supers: Vec::new(), + } + }) + .collect(); + let header = hdrl(&streams, main, opts.max_riffs, 0); + if (header.len() + 24) as u64 > opts.riff_limit / 2 { + return Err(invalid("the header list takes more than half of riff_limit")); + } + let base = w.stream_position()?; + let mut head = Vec::with_capacity(header.len() + 24); + head.extend_from_slice(b"RIFF"); + le32(&mut head, 0); + head.extend_from_slice(b"AVI "); + head.extend_from_slice(&header); + let movi = head.len() as u64; + head.extend_from_slice(b"LIST"); + le32(&mut head, 0); + head.extend_from_slice(b"movi"); + w.write_all(&head)?; + Ok(AviWriter { + w, + base, + opts, + streams, + main, + pos: head.len() as u64, + hdrl_len: header.len(), + riff: Riff { start: 0, movi }, + riffs: 1, + idx1: Vec::new(), + first_frames: 0, + }) + } + + /// Bytes the current RIFF would take if it were closed after one more chunk of `chunk_len` + /// bytes for `stream`: its chunks, the standard indexes and (first RIFF) `idx1`. + fn riff_size_with(&self, stream: usize, chunk_len: u64) -> u64 { + let ix: u64 = self.streams.iter().enumerate().map(|(i, s)| 32 + 8 * (s.entries.len() as u64 + u64::from(i == stream))).sum(); + let idx1 = if self.riffs == 1 { 8 + 16 * (self.idx1.len() as u64 + 1) } else { 0 }; + self.pos - self.riff.start + chunk_len + ix + idx1 + } + + /// Write one chunk of stream `stream`: a video frame (`key` for a key frame; an empty chunk + /// repeats the previous picture) or a run of audio. Chunks go into the file in call order, + /// so the caller interleaves the streams. + pub fn write_chunk(&mut self, stream: usize, data: &[u8], key: bool) -> Result<()> { + if stream >= self.streams.len() { + return Err(invalid(format!("no stream {stream}"))); + } + let len = data.len() as u64; + let chunk_len = 8 + len + (len & 1); + if self.riff_size_with(stream, chunk_len) > self.opts.riff_limit && self.streams.iter().any(|s| !s.entries.is_empty()) { + self.next_riff()?; + } + if self.riff_size_with(stream, chunk_len) > self.opts.riff_limit { + return Err(invalid(format!("a {len}-byte chunk does not fit in a RIFF of riff_limit {} bytes", self.opts.riff_limit))); + } + let index_base = self.riff.movi + 8; + let s = &mut self.streams[stream]; + let at = self.pos; + let mut head = Vec::with_capacity(8); + head.extend_from_slice(&s.id); + le32(&mut head, len as u32); + self.w.write_all(&head)?; + self.w.write_all(data)?; + if len & 1 == 1 { + self.w.write_all(&[0])?; + } + self.pos += chunk_len; + // riff_limit ≤ 2 GiB keeps every offset in the RIFF below 2^31 + s.entries.push(Entry { offset: (at + 8 - index_base) as u32, size: len as u32, key }); + s.duration += s.units(len); + s.chunks += 1; + s.bytes += len; + s.largest = s.largest.max(len as u32); + if self.riffs == 1 { + let mut e = [0u8; 16]; + e[..4].copy_from_slice(&s.id); + e[4..8].copy_from_slice(&(if key { AVIIF_KEYFRAME } else { 0 }).to_le_bytes()); + e[8..12].copy_from_slice(&((at - index_base) as u32).to_le_bytes()); + e[12..].copy_from_slice(&(len as u32).to_le_bytes()); + self.idx1.push(e); + if Some(stream) == self.main { + self.first_frames += 1; + } + } + Ok(()) + } + + fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<()> { + self.w.seek(SeekFrom::Start(self.base + at))?; + self.w.write_all(bytes)?; + self.w.seek(SeekFrom::Start(self.base + self.pos))?; + Ok(()) + } + + fn put(&mut self, bytes: &[u8]) -> Result<()> { + self.w.write_all(bytes)?; + self.pos += bytes.len() as u64; + Ok(()) + } + + /// Close the current RIFF: the standard indexes at the end of its `movi`, the sizes, and + /// (first RIFF) `idx1`. + fn end_riff(&mut self) -> Result<()> { + let index_base = self.riff.movi + 8; + for i in 0..self.streams.len() { + let s = &mut self.streams[i]; + let mut ix = Vec::with_capacity(24 + 8 * s.entries.len()); + le16(&mut ix, 2); // wLongsPerEntry + ix.push(0); // bIndexSubType + ix.push(1); // bIndexType: AVI_INDEX_OF_CHUNKS + le32(&mut ix, s.entries.len() as u32); + ix.extend_from_slice(&s.id); + ix.extend_from_slice(&index_base.to_le_bytes()); + le32(&mut ix, 0); + for e in s.entries.drain(..) { + le32(&mut ix, e.offset); + le32(&mut ix, e.size | if e.key { 0 } else { NOT_KEY }); + } + let at = self.pos; + let id = [b'i', b'x', s.id[0], s.id[1]]; + let c = chunk(&id, &ix); + s.supers.push(SuperEntry { at, size: c.len() as u32, duration: sat32(s.duration) }); + s.duration = 0; + self.put(&c)?; + } + let movi_size = sat32(self.pos - self.riff.movi - 8); + self.patch(self.riff.movi + 4, &movi_size.to_le_bytes())?; + if self.riffs == 1 { + let idx1 = std::mem::take(&mut self.idx1).concat(); + self.put(&chunk(b"idx1", &idx1))?; + } + let riff_size = sat32(self.pos - self.riff.start - 8); + self.patch(self.riff.start + 4, &riff_size.to_le_bytes()) + } + + /// Close the current RIFF and start a `RIFF AVIX` extension. + fn next_riff(&mut self) -> Result<()> { + if self.riffs >= self.opts.max_riffs { + return Err(invalid(format!("the file needs more than max_riffs ({}) RIFFs", self.opts.max_riffs))); + } + self.end_riff()?; + let start = self.pos; + let mut head = Vec::with_capacity(24); + head.extend_from_slice(b"RIFF"); + le32(&mut head, 0); + head.extend_from_slice(b"AVIX"); + head.extend_from_slice(b"LIST"); + le32(&mut head, 0); + head.extend_from_slice(b"movi"); + self.put(&head)?; + self.riff = Riff { start, movi: start + 12 }; + self.riffs += 1; + Ok(()) + } + + /// Close the file: the last RIFF's indexes and sizes, then the header list with the final + /// counts. Returns the writer, positioned at the end of the file. + pub fn finish(mut self) -> Result { + self.end_riff()?; + let header = hdrl(&self.streams, self.main, self.opts.max_riffs, self.first_frames); + if header.len() != self.hdrl_len { + return Err(invalid("the header list changed size")); + } + self.patch(12, &header)?; + self.w.flush()?; + Ok(self.w) + } +} diff --git a/crates/media/src/avi/mux_tests.rs b/crates/media/src/avi/mux_tests.rs new file mode 100644 index 000000000..96f77a04c --- /dev/null +++ b/crates/media/src/avi/mux_tests.rs @@ -0,0 +1,205 @@ +//! The AVI muxer read back by this module's demuxer, through every index it writes. + +use std::io::Cursor; + +use super::*; + +/// Chunk `i` of a test stream: sizes vary (odd, even, empty) and the bytes name the chunk. +fn payload(stream: usize, i: usize) -> Vec { + let len = match i % 7 { + 0 => 1500 + i * 3, + 3 => 0, + 5 => 1, + _ => 200 + (i * 37) % 900, + }; + (0..len).map(|k| (k as u32 ^ (i as u32) << 3 ^ (stream as u32) << 7) as u8).collect() +} + +fn video() -> VideoSpec { + let mut v = VideoSpec::new(*b"XVID", 320, 240, 30000, 1001); + v.format.extra = vec![0, 0, 1, 0xB0, 3]; + v.name = Some("picture".into()); + v +} + +fn pcm() -> AudioSpec { + let mut a = AudioSpec::pcm(48_000, 2, 16); + a.name = Some("sound".into()); + a +} + +/// Each stream's chunks: bytes and key frame. +type Written = [Vec<(Vec, bool)>; 2]; + +/// Write `frames` video frames (every 12th a key frame) each followed by an audio chunk of 4·k +/// bytes, returning the file and what was written per stream. +fn write(frames: usize, opts: MuxOptions) -> (Vec, Written) { + let mut w = AviWriter::new(Cursor::new(Vec::new()), vec![StreamSpec::Video(video()), StreamSpec::Audio(pcm())], opts).unwrap(); + let mut written: Written = [Vec::new(), Vec::new()]; + for i in 0..frames { + let v = payload(0, i); + let key = i % 12 == 0; + w.write_chunk(0, &v, key).unwrap(); + written[0].push((v, key)); + let a: Vec = payload(1, i).into_iter().take(4 * (1 + i % 400)).collect(); + let a = a[..a.len() / 4 * 4].to_vec(); + w.write_chunk(1, &a, true).unwrap(); + written[1].push((a, true)); + } + (w.finish().unwrap().into_inner(), written) +} + +fn tables(file: &[u8], f: &AviFile) -> Written { + std::array::from_fn(|s| (0..f.streams[s].chunks.len()).map(|i| (f.read_chunk(file, s, i).unwrap(), f.streams[s].chunks[i].key)).collect()) +} + +/// The top-level chunks of a file: (id, form, size). +fn riffs(file: &[u8]) -> Vec<([u8; 4], [u8; 4], u64)> { + children(&file, 0, file.len() as u64).into_iter().map(|(id, size, data)| (id, fourcc(file, data as usize), size)).collect() +} + +#[test] +fn headers_round_trip() { + let (file, written) = write(30, MuxOptions::default()); + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(f.header, MainHeader { micro_sec_per_frame: 33367, flags: 0x110, total_frames: 30, streams: 2, width: 320, height: 240 }); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, v.handler, v.scale, v.rate, v.length, v.sample_size), (StreamKind::Video, *b"XVID", 1001, 30000, 30, 0)); + assert_eq!(v.video.as_ref(), Some(&video().format)); + assert_eq!(v.name.as_deref(), Some("picture")); + let audio_bytes: usize = written[1].iter().map(|c| c.0.len()).sum(); + assert_eq!((a.kind, a.scale, a.rate, a.sample_size, a.length as usize), (StreamKind::Audio, 4, 192_000, 4, audio_bytes / 4)); + assert_eq!(a.audio.as_ref(), Some(&pcm().format)); + assert_eq!(a.name.as_deref(), Some("sound")); + assert_eq!(v.codec_params().codec, crate::CodecId::Mpeg4Video); + assert_eq!(a.codec_params().codec, crate::CodecId::Pcm); + // one RIFF: hdrl, movi, idx1 + assert_eq!(riffs(&file).iter().map(|r| (r.0, r.1)).collect::>(), [(*b"RIFF", *b"AVI ")]); + assert_eq!(riffs(&file)[0].2 + 8, file.len() as u64); +} + +#[test] +fn every_index_describes_the_same_chunks() { + let (file, written) = write(40, MuxOptions::default()); + for (opts, source) in [ + (OpenOptions::default(), IndexSource::OpenDml), + (OpenOptions { ignore_odml: true, ignore_idx1: false }, IndexSource::Idx1), + (OpenOptions { ignore_odml: true, ignore_idx1: true }, IndexSource::Scan), + ] { + let f = open_with(&file, opts).unwrap(); + assert_eq!(f.index, source); + let got = tables(&file, &f); + assert_eq!(got[1], written[1], "{source:?}: audio"); + if source == IndexSource::Scan { + // a scan cannot tell key frames + assert_eq!(got[0].iter().map(|c| &c.0).collect::>(), written[0].iter().map(|c| &c.0).collect::>()); + } else { + assert_eq!(got[0], written[0], "{source:?}: video"); + } + } +} + +#[test] +fn large_files_continue_in_avix_extensions() { + let opts = MuxOptions { riff_limit: 24 << 10, max_riffs: 64 }; + let (file, written) = write(200, opts); + let r = riffs(&file); + assert!(r.len() > 3, "{} RIFFs", r.len()); + assert_eq!((r[0].0, r[0].1), (*b"RIFF", *b"AVI ")); + assert!(r[1..].iter().all(|x| (x.0, x.1) == (*b"RIFF", *b"AVIX"))); + assert!(r.iter().all(|x| x.2 + 8 <= opts.riff_limit), "a RIFF is over the limit: {r:?}"); + assert_eq!(r.iter().map(|x| x.2 + 8).sum::(), file.len() as u64); + + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(tables(&file, &f), written); + assert_eq!(f.streams[0].length, 200); + // avih counts the first RIFF's frames; strh the whole stream + let first = f.streams[0].chunks.iter().filter(|c| c.offset < r[0].2).count() as u32; + assert_eq!(f.header.total_frames, first); + assert!(first < 200); + // idx1 covers the first RIFF and the AVIX extensions are scanned + let f = open_with(&file, OpenOptions { ignore_odml: true, ignore_idx1: false }).unwrap(); + assert_eq!(f.index, IndexSource::Idx1); + assert_eq!(tables(&file, &f)[1], written[1]); + assert_eq!(f.streams[0].chunks.len(), 200); +} + +#[test] +fn a_file_can_start_inside_the_output() { + let mut out = Cursor::new(b"prefix".to_vec()); + out.set_position(6); + let mut w = AviWriter::new(out, vec![StreamSpec::Video(video())], MuxOptions::default()).unwrap(); + w.write_chunk(0, &[1, 2, 3], true).unwrap(); + let bytes = w.finish().unwrap().into_inner(); + assert_eq!(&bytes[..6], b"prefix"); + let file = &bytes[6..]; + let f = open(&file).unwrap(); + assert_eq!(f.read_chunk(&file, 0, 0).unwrap(), [1, 2, 3]); + assert_eq!(f.header.total_frames, 1); +} + +#[test] +fn packet_timed_audio_counts_chunks() { + let mut mp3 = AudioSpec::pcm(44_100, 2, 0); + mp3.format = WaveFormat { + format_tag: 0x55, + channels: 2, + sample_rate: 44_100, + avg_bytes_per_sec: 16_000, + block_align: 1, + bits_per_sample: 0, + extra: vec![0; 12], + }; + mp3.timing = AudioTiming::Packets { samples_per_packet: 1152 }; + let mut w = AviWriter::new(Cursor::new(Vec::new()), vec![StreamSpec::Audio(mp3)], MuxOptions::default()).unwrap(); + for i in 0..10 { + w.write_chunk(0, &payload(0, i), true).unwrap(); + } + let file = w.finish().unwrap().into_inner(); + let f = open(&file).unwrap(); + let a = &f.streams[0]; + assert_eq!((a.scale, a.rate, a.sample_size, a.length), (1152, 44_100, 0, 10)); + assert_eq!(a.codec_params().codec, crate::CodecId::Mp3); + assert_eq!(f.header.width, 0); +} + +#[test] +fn codec_descriptions_pick_their_fourcc() { + let p = crate::CodecParams::new(crate::CodecId::Mpeg4Video).with_size(640, 360).with_extradata(vec![0, 0, 1, 0xB0]); + let v = VideoSpec::for_codec(&p, 25, 1).unwrap(); + assert_eq!((v.format.compression, v.format.width, v.format.height, &v.format.extra[..]), (*b"FMP4", 640, 360, &[0, 0, 1, 0xB0][..])); + assert!(VideoSpec::for_codec(&crate::CodecParams::new(crate::CodecId::H264), 25, 1).is_none(), "no size"); + assert!(VideoSpec::for_codec(&crate::CodecParams::new(crate::CodecId::Aac).with_size(1, 1), 25, 1).is_none()); +} + +#[test] +fn bad_input_is_an_error() { + let new = |streams: Vec, opts| AviWriter::new(Cursor::new(Vec::new()), streams, opts).err(); + let d = MuxOptions::default(); + assert!(new(Vec::new(), d).is_some()); + assert!(new(vec![StreamSpec::Video(video()); 101], d).is_some()); + assert!(new(vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 1000, ..d }).is_some()); + assert!(new(vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 3 << 30, ..d }).is_some()); + assert!(new(vec![StreamSpec::Video(video())], MuxOptions { max_riffs: 0, ..d }).is_some()); + assert!(new(vec![StreamSpec::Video(VideoSpec::new(*b"XVID", 16, 16, 0, 1))], d).is_some()); + let mut a = pcm(); + a.format.block_align = 0; + assert!(new(vec![StreamSpec::Audio(a)], d).is_some()); + let mut a = pcm(); + a.timing = AudioTiming::Packets { samples_per_packet: 0 }; + assert!(new(vec![StreamSpec::Audio(a)], d).is_some()); + // max_riffs large enough that the header list cannot fit in half a small RIFF + assert!(new(vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 16 << 10, max_riffs: 1000 }).is_some()); + + let mut w = AviWriter::new(Cursor::new(Vec::new()), vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 16 << 10, max_riffs: 2 }).unwrap(); + assert!(w.write_chunk(1, &[0], true).is_err(), "no such stream"); + assert!(w.write_chunk(0, &vec![0; 20 << 10], true).is_err(), "larger than a RIFF"); + w.write_chunk(0, &vec![0; 10 << 10], true).unwrap(); + w.write_chunk(0, &vec![0; 10 << 10], true).unwrap(); // the second RIFF + assert!(w.write_chunk(0, &vec![0; 10 << 10], true).is_err(), "a third RIFF is past max_riffs"); + // what was written still closes into a readable file + let file = w.finish().unwrap().into_inner(); + assert_eq!(open(&file).unwrap().streams[0].chunks.len(), 2); +} diff --git a/crates/media/tests/oracle_avi_mux.rs b/crates/media/tests/oracle_avi_mux.rs new file mode 100644 index 000000000..f78acc92d --- /dev/null +++ b/crates/media/tests/oracle_avi_mux.rs @@ -0,0 +1,156 @@ +//! AVI files from [`AviWriter`] read by ffmpeg and ffprobe (skipped without them): ffmpeg makes +//! an MPEG-4 Part 2 elementary stream and PCM, the muxer interleaves them, and ffmpeg must decode +//! the AVI to exactly what it decodes from the sources, in one RIFF and across AVIX extensions. + +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use craft_media::avi::{AudioSpec, AviWriter, MuxOptions, StreamSpec, VideoSpec}; + +const FRAMES: usize = 50; +/// 25 frames a second of 48 kHz stereo 16-bit PCM: bytes of audio a frame. +const AUDIO_PER_FRAME: usize = 48_000 / 25 * 4; + +fn tools() -> Option<(PathBuf, PathBuf)> { + let ff = craft_media_testkit::ffmpeg_or_skip("avi muxer oracle")?; + let probe = craft_media_testkit::ffprobe()?; + Some((ff, probe)) +} + +fn run(cmd: &mut Command) -> Vec { + let out = cmd.output().expect("run tool"); + assert!(out.status.success(), "{:?}: {}", cmd, String::from_utf8_lossy(&out.stderr)); + // A plain WAVEFORMATEX has no channel mask, so ffmpeg notes the layout it assumes for any + // stereo PCM in AVI, its own files included. Every other line is a failure. + let diag: Vec = + String::from_utf8_lossy(&out.stderr).lines().filter(|l| !l.contains("Guessed Channel Layout: stereo")).map(str::to_owned).collect(); + assert!(diag.is_empty(), "{cmd:?} reported: {}", diag.join("\n")); + out.stdout +} + +/// The sources: an MPEG-4 Part 2 elementary stream (I-VOP every 12 frames, no B-VOPs) and raw +/// PCM, both from ffmpeg. +fn sources(ff: &Path, dir: &Path) -> (Vec, Vec) { + let m4v = dir.join("video.m4v"); + run(Command::new(ff) + .args(["-v", "warning", "-y", "-f", "lavfi", "-i", "testsrc2=s=160x120:r=25"]) + .args(["-frames:v", &FRAMES.to_string(), "-c:v", "mpeg4", "-threads", "1", "-q:v", "5", "-g", "12", "-bf", "0", "-f", "m4v"]) + .arg(&m4v)); + let pcm = run(Command::new(ff) + .args(["-v", "warning", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"]) + .args(["-t", "2", "-ac", "2", "-f", "s16le", "-"])); + (std::fs::read(&m4v).expect("read m4v"), pcm) +} + +/// The VOPs of an elementary stream: each runs from its start code (the first from the start of +/// the stream, so it carries the configuration headers) to the next. An I-VOP is a key frame. +fn vops(es: &[u8]) -> Vec<(&[u8], bool)> { + let starts: Vec = es.windows(4).enumerate().filter(|(_, w)| *w == [0, 0, 1, 0xB6]).map(|(i, _)| i).collect(); + let mut out = Vec::new(); + for (k, &s) in starts.iter().enumerate() { + let from = if k == 0 { 0 } else { s }; + let to = starts.get(k + 1).copied().unwrap_or(es.len()); + out.push((&es[from..to], es.get(s + 4).is_some_and(|b| b >> 6 == 0))); + } + out +} + +fn mux(video: &[(&[u8], bool)], pcm: &[u8], opts: MuxOptions) -> Vec { + let streams = vec![StreamSpec::Video(VideoSpec::new(*b"XVID", 160, 120, 25, 1)), StreamSpec::Audio(AudioSpec::pcm(48_000, 2, 16))]; + let mut w = AviWriter::new(Cursor::new(Vec::new()), streams, opts).expect("writer"); + for (i, (vop, key)) in video.iter().enumerate() { + w.write_chunk(0, vop, *key).expect("video"); + let a = pcm.get(i * AUDIO_PER_FRAME..((i + 1) * AUDIO_PER_FRAME).min(pcm.len())).unwrap_or_default(); + w.write_chunk(1, a, true).expect("audio"); + } + w.finish().expect("finish").into_inner() +} + +fn decode(ff: &Path, input: &Path, format: Option<&str>, map: &str, out: &[&str]) -> Vec { + let mut c = Command::new(ff); + c.args(["-v", "warning", "-nostdin"]); + if let Some(f) = format { + c.args(["-f", f]); + } + c.arg("-i").arg(input).args(["-map", map]).args(out).arg("-"); + run(&mut c) +} + +#[test] +fn ffmpeg_decodes_muxed_files_like_their_sources() { + let Some((ff, probe)) = tools() else { return }; + let dir = craft_media_testkit::private_dir("avi-mux"); + let (es, pcm) = sources(&ff, &dir); + let video = vops(&es); + assert_eq!(video.len(), FRAMES); + assert!(video.iter().filter(|v| v.1).count() >= 4); + let want_video = decode(&ff, &dir.join("video.m4v"), Some("m4v"), "0:v", &["-f", "rawvideo", "-pix_fmt", "yuv420p"]); + + for (name, opts) in [("one_riff.avi", MuxOptions::default()), ("avix.avi", MuxOptions { riff_limit: 64 << 10, max_riffs: 64 })] { + let file = mux(&video, &pcm, opts); + let path = dir.join(name); + std::fs::write(&path, &file).expect("write avi"); + if name == "avix.avi" { + assert!(file.windows(4).filter(|w| *w == b"AVIX").count() >= 3, "{name}: AVIX extensions"); + } + let got_video = decode(&ff, &path, None, "0:v", &["-f", "rawvideo", "-pix_fmt", "yuv420p"]); + assert!(got_video == want_video, "{name}: decoded pictures differ from the elementary stream's"); + // ffmpeg reads an interleaved file front to back; a seek goes through the index (OpenDML + // when the file has it). 1.0 s lands after key frame 24, so frame 25 comes out first. + for at in ["1.0", "1.6"] { + let frame = (at.parse::().unwrap_or(0.0) * 25.0).round() as usize; + let size = 160 * 120 * 3 / 2; + let mut c = Command::new(&ff); + c.args(["-v", "warning", "-nostdin", "-ss", at, "-i"]).arg(&path).args([ + "-map", + "0:v", + "-frames:v", + "1", + "-f", + "rawvideo", + "-pix_fmt", + "yuv420p", + "-", + ]); + let got = run(&mut c); + assert!(got == want_video[frame * size..(frame + 1) * size], "{name}: seeking to {at} s gave another picture than frame {frame}"); + } + let got_audio = decode(&ff, &path, None, "0:a", &["-f", "s16le"]); + assert!(got_audio == pcm, "{name}: decoded audio differs from the PCM ({} vs {} bytes)", got_audio.len(), pcm.len()); + + let info = String::from_utf8_lossy(&run(Command::new(&probe) + .args([ + "-v", + "error", + "-count_packets", + "-show_entries", + "stream=codec_name,codec_tag_string,width,height,avg_frame_rate,nb_read_packets,sample_rate,channels", + "-of", + "default=nw=1", + ]) + .arg(&path))) + .into_owned(); + for want in [ + "codec_name=mpeg4", + "codec_tag_string=XVID", + "width=160", + "height=120", + "avg_frame_rate=25/1", + "nb_read_packets=50", + "codec_name=pcm_s16le", + "sample_rate=48000", + "channels=2", + ] { + assert!(info.lines().any(|l| l == want), "{name}: ffprobe lacks {want}:\n{info}"); + } + let duration = String::from_utf8_lossy(&run(Command::new(&probe) + .args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"]) + .arg(&path))) + .trim() + .parse::() + .unwrap_or(0.0); + assert!((duration - 2.0).abs() < 0.05, "{name}: duration {duration}"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/video/Cargo.toml b/crates/video/Cargo.toml index 0e31f8e26..ea8d4773d 100644 --- a/crates/video/Cargo.toml +++ b/crates/video/Cargo.toml @@ -58,6 +58,8 @@ rayon = { workspace = true, optional = true } [dev-dependencies] craft-media-testkit = { workspace = true } +# The AVI muxer, for the MPEG-4 Part 2 encoder's end-to-end test. +craft-media = { workspace = true, features = ["avi"] } [[test]] name = "dnx" diff --git a/crates/video/tests/mpeg4_encode/main.rs b/crates/video/tests/mpeg4_encode/main.rs index 0b53c5caf..8185acbc0 100644 --- a/crates/video/tests/mpeg4_encode/main.rs +++ b/crates/video/tests/mpeg4_encode/main.rs @@ -322,3 +322,37 @@ fn extreme_content_at_the_finest_quantiser() { assert!(d >= 45.0, "frame {i}: {d:.1} dB from the reconstruction"); } } + +/// The encoder's packets in an AVI (the track description through `VideoSpec::for_codec`) decode +/// in ffmpeg to exactly what the raw stream decodes to. Tagged `XVID` they would not: ffmpeg then +/// decodes with Xvid's IDCT. +#[test] +fn packets_play_from_an_avi_file() { + use craft_media::avi::{AviWriter, MuxOptions, StreamSpec, VideoSpec}; + let Some(ff) = ffmpeg() else { return }; + let mut cfg = EncoderConfig::new(160, 96, 25, 1); + cfg.keyint = 10; + let Encoded { enc, packets, .. } = encode(cfg, 30); + let spec = VideoSpec::for_codec(&enc.codec_params(), 25, 1).expect("an AVI FourCC for MPEG-4 Part 2"); + assert_eq!(spec.format.compression, *b"FMP4"); + let mut w = AviWriter::new(std::io::Cursor::new(Vec::new()), vec![StreamSpec::Video(spec)], MuxOptions::default()).expect("writer"); + for p in &packets { + w.write_chunk(0, &p.data, p.keyframe).expect("chunk"); + } + let avi = w.finish().expect("finish").into_inner(); + let dir = out_dir("avi"); + let (avi_path, es_path) = (dir.join("stream.avi"), dir.join("stream.m4v")); + std::fs::write(&avi_path, avi).expect("write avi"); + write_stream(&es_path, &packets); + let (_, want) = ffmpeg_decode(&ff, &es_path); + let out = Command::new(&ff) + .args(["-hide_banner", "-nostdin", "-v", "warning", "-i"]) + .arg(&avi_path) + .args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"]) + .output() + .expect("run ffmpeg"); + let diag = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success() && diag.trim().is_empty(), "{diag}"); + assert_eq!(out.stdout.len(), 160 * 96 * 3 / 2 * 30); + assert!(out.stdout == want, "the AVI decodes differently from the raw stream"); +} From 8cdd25ce0a307bbe073e2af217d68068ec558f95 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:38:38 +0000 Subject: [PATCH 013/118] feat(figcraft-format): add native document header contract --- Cargo.lock | 9 ++++ Cargo.toml | 1 + crates/figcraft-format/Cargo.toml | 16 +++++++ crates/figcraft-format/src/lib.rs | 77 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 crates/figcraft-format/Cargo.toml create mode 100644 crates/figcraft-format/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 266217a1a..1bbfe7965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-figcraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a32e1df27..b07dac4be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ craft-tiff = { path = "crates/tiff" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } +craft-figcraft-format = { path = "crates/figcraft-format" } craft-xml = { path = "crates/xml" } craft-video = { path = "crates/video" } craft-web-api = { path = "crates/web-api" } diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml new file mode 100644 index 000000000..3275fbcd6 --- /dev/null +++ b/crates/figcraft-format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-figcraft-format" +description = "The native FigCraft document JSON and archive contract." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs new file mode 100644 index 000000000..5df23cf24 --- /dev/null +++ b/crates/figcraft-format/src/lib.rs @@ -0,0 +1,77 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! The neutral JSON header and version contract for FigCraft's native project format. +//! App-specific document fields remain a generic flattened payload. +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const EXTENSION: &str = "figcraft"; +pub const FORMAT: &str = "figcraft"; +pub const VERSION: u32 = 1; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Header { pub version: Option, pub images: Option> } + +pub fn read_header(text: &str) -> Result { serde_json::from_str(text) } + +impl<'de> Deserialize<'de> for Header { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> Visitor<'de> for V { + type Value = Header; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("a FigCraft document object") } + fn visit_map>(self, mut m: A) -> Result { + let mut h = Header::default(); + while let Some(k) = m.next_key::()? { + match k.as_str() { + "version" => h.version = m.next_value::()?.as_u64(), + "imageIndex" => h.images = m.next_value::()?.as_array().map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()), + _ => { m.next_value::()?; } + } + } + Ok(h) + } + } + d.deserialize_map(V) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("this file was saved by a newer FigCraft (format version {0})")] +pub struct NewerVersion(pub u32); + +pub fn check_version(h: &Header) -> Result<(), NewerVersion> { + if let Some(v) = h.version && v > u64::from(VERSION) { return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ImageIndexEntry { pub hash: String, pub width: u32, pub height: u32 } + +pub fn write_document_json(doc: &D, images: &[ImageIndexEntry], w: W) -> Result<(), serde_json::Error> { + #[derive(Serialize)] + struct Flat<'a, D: ?Sized> { format: &'static str, version: u32, #[serde(rename="imageIndex")] images: &'a [ImageIndexEntry], #[serde(flatten)] document: &'a D } + Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn header_skips_app_fields() { + let h = read_header(r#"{"version":1,"imageIndex":[{"hash":"abc"}],"nested":{"unused":true}}"#).unwrap(); + assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()])); + } + #[test] + fn newer_version_is_reported_before_document_decode() { + assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); + assert_eq!(check_version(&Header { version: Some(u64::MAX), images: None }), Err(NewerVersion(u32::MAX))); + } + #[test] + fn writer_flattens_document_fields() { + let doc = serde_json::json!({"name":"example"}); let mut bytes=Vec::new(); + write_document_json(&doc, &[], &mut bytes).unwrap(); + let v: Value=serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["format"], FORMAT); assert_eq!(v["version"], VERSION); assert_eq!(v["name"], "example"); + } +} From 111b522d05bcc75bb4f15eaa91496eb5f9c6a54f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:41:10 +0000 Subject: [PATCH 014/118] feat(figcraft-format): add bounded native archive access --- Cargo.lock | 10 +++ crates/figcraft-format/Cargo.toml | 1 + crates/figcraft-format/src/lib.rs | 137 +++++++++++++++++++++++++++--- 3 files changed, 136 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..fda532570 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1445,6 +1445,16 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-figcraft-format" +version = "0.1.0" +dependencies = [ + "craft-zip", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-figma-rest" version = "0.1.0" diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml index 3275fbcd6..22682034b 100644 --- a/crates/figcraft-format/Cargo.toml +++ b/crates/figcraft-format/Cargo.toml @@ -13,4 +13,5 @@ workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } +craft-zip = { workspace = true } thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 5df23cf24..fd2c6ecf2 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -10,23 +10,37 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Header { pub version: Option, pub images: Option> } +pub struct Header { + pub version: Option, + pub images: Option>, +} -pub fn read_header(text: &str) -> Result { serde_json::from_str(text) } +pub fn read_header(text: &str) -> Result { + serde_json::from_str(text) +} impl<'de> Deserialize<'de> for Header { fn deserialize>(d: D) -> Result { struct V; impl<'de> Visitor<'de> for V { type Value = Header; - fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("a FigCraft document object") } + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a FigCraft document object") + } fn visit_map>(self, mut m: A) -> Result { let mut h = Header::default(); while let Some(k) = m.next_key::()? { match k.as_str() { "version" => h.version = m.next_value::()?.as_u64(), - "imageIndex" => h.images = m.next_value::()?.as_array().map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()), - _ => { m.next_value::()?; } + "imageIndex" => { + h.images = m + .next_value::()? + .as_array() + .map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()) + } + _ => { + m.next_value::()?; + } } } Ok(h) @@ -41,16 +55,31 @@ impl<'de> Deserialize<'de> for Header { pub struct NewerVersion(pub u32); pub fn check_version(h: &Header) -> Result<(), NewerVersion> { - if let Some(v) = h.version && v > u64::from(VERSION) { return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); } + if let Some(v) = h.version + && v > u64::from(VERSION) + { + return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); + } Ok(()) } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct ImageIndexEntry { pub hash: String, pub width: u32, pub height: u32 } +pub struct ImageIndexEntry { + pub hash: String, + pub width: u32, + pub height: u32, +} pub fn write_document_json(doc: &D, images: &[ImageIndexEntry], w: W) -> Result<(), serde_json::Error> { #[derive(Serialize)] - struct Flat<'a, D: ?Sized> { format: &'static str, version: u32, #[serde(rename="imageIndex")] images: &'a [ImageIndexEntry], #[serde(flatten)] document: &'a D } + struct Flat<'a, D: ?Sized> { + format: &'static str, + version: u32, + #[serde(rename = "imageIndex")] + images: &'a [ImageIndexEntry], + #[serde(flatten)] + document: &'a D, + } Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w)) } @@ -60,7 +89,8 @@ mod tests { #[test] fn header_skips_app_fields() { let h = read_header(r#"{"version":1,"imageIndex":[{"hash":"abc"}],"nested":{"unused":true}}"#).unwrap(); - assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()])); + assert_eq!(h.version, Some(1)); + assert_eq!(h.images, Some(vec!["abc".into()])); } #[test] fn newer_version_is_reported_before_document_decode() { @@ -69,9 +99,92 @@ mod tests { } #[test] fn writer_flattens_document_fields() { - let doc = serde_json::json!({"name":"example"}); let mut bytes=Vec::new(); + let doc = serde_json::json!({"name":"example"}); + let mut bytes = Vec::new(); write_document_json(&doc, &[], &mut bytes).unwrap(); - let v: Value=serde_json::from_slice(&bytes).unwrap(); - assert_eq!(v["format"], FORMAT); assert_eq!(v["version"], VERSION); assert_eq!(v["name"], "example"); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["format"], FORMAT); + assert_eq!(v["version"], VERSION); + assert_eq!(v["name"], "example"); + } +} + +/// Limits for the native ZIP package reader. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ArchiveLimits { + pub max_entries: u64, + pub max_total_bytes: u64, +} + +/// A bounded reader for a native `.figcraft` ZIP package. +pub struct Archive<'a> { + inner: craft_zip::Archive<'a>, +} + +impl<'a> Archive<'a> { + pub fn open(bytes: &'a [u8], limits: ArchiveLimits) -> Result { + let inner = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: limits.max_entries, max_total_bytes: limits.max_total_bytes })?; + Ok(Self { inner }) + } + + pub fn names(&self) -> impl Iterator> { + self.inner.names() + } + + pub fn has_duplicate_names(&self) -> bool { + self.inner.has_duplicate_names() + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.contains(name) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + self.inner.read(name, max_bytes) + } + + pub fn budget_used(&self) -> u64 { + self.inner.budget().used() + } +} + +/// One member to write into a native ZIP package. +#[derive(Clone, Copy, Debug)] +pub struct Member<'a> { + pub name: &'a str, + pub bytes: &'a [u8], + pub compression: craft_zip::Compression, +} + +/// Write a deterministic native ZIP package using the shared ZIP writer. +pub fn write_archive<'a>(members: impl IntoIterator>) -> Result, craft_zip::Error> { + let mut writer = craft_zip::Writer::new(); + for member in members { + writer.add(member.name, member.bytes, member.compression)?; + } + writer.finish() +} + +#[cfg(test)] +mod archive_tests { + use super::*; + + #[test] + fn bounded_archive_reader_and_writer_round_trip_members() { + let bytes = + write_archive([Member { name: "document.json", bytes: br#"{"version":1}"#, compression: craft_zip::Compression::Deflated }]).unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); + assert!(archive.contains("document.json")); + assert_eq!(archive.read("document.json", 32).unwrap(), Some(br#"{"version":1}"#.to_vec())); + assert_eq!(archive.budget_used(), 13); + } + + #[test] + fn archive_reader_enforces_entry_and_total_limits() { + let bytes = write_archive([Member { name: "document.json", bytes: b"123456789", compression: craft_zip::Compression::Deflated }]).unwrap(); + let mut small_entry = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 100 }).unwrap(); + assert!(small_entry.read("document.json", 8).is_err()); + let mut small_total = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 8 }).unwrap(); + assert!(small_total.read("document.json", 100).is_err()); } } From 30b1e426e0ef07e0606e0ab09ec540eca5665bb6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:49:46 +0000 Subject: [PATCH 015/118] feat(figcraft-format): stream native document JSON --- crates/figcraft-format/src/lib.rs | 57 +++++++++++++++++++++++++++++++ crates/zip/src/write.rs | 18 +++++----- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index fd2c6ecf2..08b9bcb9a 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -188,3 +188,60 @@ mod archive_tests { assert!(small_total.read("document.json", 100).is_err()); } } + +/// A native archive writer that keeps the streaming document serializer out of the consumer. +pub struct ArchiveWriter { + inner: craft_zip::Writer, +} + +impl ArchiveWriter { + pub fn new() -> Self { + Self { inner: craft_zip::Writer::new() } + } + + pub fn add(&mut self, name: &str, bytes: &[u8], compression: craft_zip::Compression) -> Result<(), craft_zip::Error> { + self.inner.add(name, bytes, compression) + } + + pub fn add_document_json(&mut self, name: &str, document: &D, images: &[ImageIndexEntry]) -> Result<(), ArchiveWriteError> { + self.inner + .add_with(name, craft_zip::Compression::Level(3), |w| write_document_json(document, images, w).map_err(std::io::Error::other)) + .map_err(ArchiveWriteError::Archive) + } + + pub fn finish(self) -> Result, craft_zip::Error> { + self.inner.finish() + } +} + +impl Default for ArchiveWriter { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ArchiveWriteError { + #[error("could not serialize native document JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("could not write native archive: {0}")] + Archive(#[from] craft_zip::Error), +} + +#[cfg(test)] +mod streaming_writer_tests { + use super::*; + + #[test] + fn native_writer_streams_flat_document_into_archive() { + let document = serde_json::json!({"name":"streamed"}); + let mut writer = ArchiveWriter::new(); + writer.add_document_json("document.json", &document, &[]).unwrap(); + let bytes = writer.finish().unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 4096 }).unwrap(); + let json = archive.read("document.json", 1024).unwrap().unwrap(); + let value: Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(value["name"], "streamed"); + assert_eq!(value["format"], FORMAT); + } +} diff --git a/crates/zip/src/write.rs b/crates/zip/src/write.rs index 2f5d7ca47..12838a3b8 100644 --- a/crates/zip/src/write.rs +++ b/crates/zip/src/write.rs @@ -33,22 +33,24 @@ impl Writer { Writer::default() } - /// Adds an entry. A name the archive already has is an error. + /// Adds an entry from a byte slice. A name the archive already has is an error. pub fn add(&mut self, name: &str, data: &[u8], compression: Compression) -> Result<(), Error> { + self.add_with(name, compression, |writer| writer.write_all(data)) + } + + /// Adds an entry by streaming it into the compressor, so large generated entries need not + /// first be buffered in memory. A name the archive already has is an error. + pub fn add_with(&mut self, name: &str, compression: Compression, write: impl FnOnce(&mut dyn Write) -> std::io::Result<()>) -> Result<(), Error> { let (method, level) = match compression { Compression::Stored => (zip::CompressionMethod::Stored, None), Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), }; - let options = zip::write::SimpleFileOptions::default() - .compression_method(method) - .compression_level(level) - .last_modified_time(zip::DateTime::default()) - // Deflate can grow incompressible data a little, so ZIP64 well before 4 GiB. - .large_file(data.len() as u64 >= u64::from(u32::MAX) / 2); + let options = + zip::write::SimpleFileOptions::default().compression_method(method).compression_level(level).last_modified_time(zip::DateTime::default()); let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); self.zip.start_file(name, options).map_err(|e| fail(&e))?; - self.zip.write_all(data).map_err(|e| fail(&e)) + write(&mut self.zip).map_err(|e| fail(&e)) } /// The finished archive. From 644e613b06ef267eed75b6fbdccfddc7217d599f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:52:59 +0000 Subject: [PATCH 016/118] feat(figcraft-format): expose native member sizes --- crates/figcraft-format/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 08b9bcb9a..df61cd065 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -139,6 +139,14 @@ impl<'a> Archive<'a> { self.inner.contains(name) } + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.declared_size(name) + } + + pub fn compressed_size(&mut self, name: &str) -> Option { + self.inner.compressed_size(name) + } + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { self.inner.read(name, max_bytes) } From f8acbd5832f661f7ab53628b007d8a431d5b7a7e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:55:26 +0000 Subject: [PATCH 017/118] feat(figcraft-format): expose archive compression modes --- crates/figcraft-format/src/lib.rs | 3 +++ crates/zip/src/write.rs | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index df61cd065..19e5f1175 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -253,3 +253,6 @@ mod streaming_writer_tests { assert_eq!(value["format"], FORMAT); } } + +/// ZIP compression modes for native archive members. +pub use craft_zip::Compression; diff --git a/crates/zip/src/write.rs b/crates/zip/src/write.rs index 12838a3b8..2b32e1e74 100644 --- a/crates/zip/src/write.rs +++ b/crates/zip/src/write.rs @@ -35,7 +35,19 @@ impl Writer { /// Adds an entry from a byte slice. A name the archive already has is an error. pub fn add(&mut self, name: &str, data: &[u8], compression: Compression) -> Result<(), Error> { - self.add_with(name, compression, |writer| writer.write_all(data)) + let (method, level) = match compression { + Compression::Stored => (zip::CompressionMethod::Stored, None), + Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), + Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), + }; + let options = zip::write::SimpleFileOptions::default() + .compression_method(method) + .compression_level(level) + .last_modified_time(zip::DateTime::default()) + .large_file(data.len() as u64 >= u64::from(u32::MAX) / 2); + let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); + self.zip.start_file(name, options).map_err(|e| fail(&e))?; + self.zip.write_all(data).map_err(|e| fail(&e)) } /// Adds an entry by streaming it into the compressor, so large generated entries need not @@ -46,8 +58,11 @@ impl Writer { Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), }; - let options = - zip::write::SimpleFileOptions::default().compression_method(method).compression_level(level).last_modified_time(zip::DateTime::default()); + let options = zip::write::SimpleFileOptions::default() + .compression_method(method) + .compression_level(level) + .last_modified_time(zip::DateTime::default()) + .large_file(true); let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); self.zip.start_file(name, options).map_err(|e| fail(&e))?; write(&mut self.zip).map_err(|e| fail(&e)) From d6f65e29e024ab8ce788d89247ae6907b7f7838b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:58:38 +0000 Subject: [PATCH 018/118] feat(figcraft-format): support seekable native readers --- Cargo.lock | 1 + crates/figcraft-format/Cargo.toml | 1 + crates/figcraft-format/src/lib.rs | 106 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fda532570..d0e9f1e6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1453,6 +1453,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml index 22682034b..009e40c01 100644 --- a/crates/figcraft-format/Cargo.toml +++ b/crates/figcraft-format/Cargo.toml @@ -14,4 +14,5 @@ workspace = true serde = { workspace = true } serde_json = { workspace = true } craft-zip = { workspace = true } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 19e5f1175..9e973475e 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -256,3 +256,109 @@ mod streaming_writer_tests { /// ZIP compression modes for native archive members. pub use craft_zip::Compression; + +/// A native ZIP reader for seekable sources that should not be copied into memory first. +pub struct SeekArchive { + inner: zip::ZipArchive, +} + +impl SeekArchive { + pub fn open(reader: R, max_entries: u64) -> Result { + let inner = zip::ZipArchive::new(reader).map_err(|e| craft_zip::Error::NotZip(e.to_string()))?; + let count = inner.len() as u64; + if count > max_entries { + return Err(craft_zip::Error::TooManyEntries { declared: count, limit: max_entries }); + } + Ok(Self { inner }) + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.index_for_name(name).is_some() + } + + pub fn names(&self) -> Vec { + self.inner.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect() + } + + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.size())) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + let Some(index) = self.inner.index_for_name(name) else { return Ok(None) }; + let mut file = self.inner.by_index(index).map_err(|e| craft_zip::Error::Entry { name: name.to_owned(), msg: e.to_string() })?; + if file.size() > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + let mut bytes = Vec::new(); + let mut limited = std::io::Read::take(&mut file, max_bytes.saturating_add(1)); + let read = std::io::Read::read_to_end(&mut limited, &mut bytes); + if let Err(error) = read { + return Err(craft_zip::Error::Entry { name: name.to_owned(), msg: error.to_string() }); + } + if bytes.len() as u64 > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + Ok(Some(bytes)) + } +} + +/// Shared access contract implemented by in-memory and seekable native archive readers. +pub trait NativeArchive { + fn contains(&self, name: &str) -> bool; + fn names(&self) -> Vec; + fn declared_size(&mut self, name: &str) -> Option; + fn compressed_size(&mut self, name: &str) -> Option; + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error>; +} + +impl NativeArchive for Archive<'_> { + fn contains(&self, name: &str) -> bool { + Archive::contains(self, name) + } + fn names(&self) -> Vec { + Archive::names(self).map(|n| n.into_owned()).collect() + } + fn declared_size(&mut self, name: &str) -> Option { + Archive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + Archive::compressed_size(self, name) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + Archive::read(self, name, max_bytes) + } +} + +impl NativeArchive for SeekArchive { + fn contains(&self, name: &str) -> bool { + SeekArchive::contains(self, name) + } + fn names(&self) -> Vec { + SeekArchive::names(self) + } + fn declared_size(&mut self, name: &str) -> Option { + SeekArchive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.compressed_size())) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + SeekArchive::read(self, name, max_bytes) + } +} + +#[cfg(test)] +mod seek_archive_tests { + use super::*; + + #[test] + fn seekable_reader_matches_bounded_in_memory_reader() { + let bytes = write_archive([Member { name: "document.json", bytes: b"payload", compression: Compression::Stored }]).unwrap(); + let mut reader = SeekArchive::open(std::io::Cursor::new(bytes), 4).unwrap(); + assert!(reader.contains("document.json")); + assert_eq!(reader.read_member("document.json", 16).unwrap(), Some(b"payload".to_vec())); + assert_eq!(reader.declared_size("document.json"), Some(7)); + assert!(reader.read_member("document.json", 3).is_err()); + } +} From e8628c526ea74ae09144508f666bf6dd92f18029 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:02:49 +0000 Subject: [PATCH 019/118] feat(figcraft-format): own native archive policy --- crates/figcraft-format/src/lib.rs | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 9e973475e..a06b39204 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -362,3 +362,60 @@ mod seek_archive_tests { assert!(reader.read_member("document.json", 3).is_err()); } } + +/// Largest individual ZIP member read by the native format. +pub const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// Most version snapshots kept on save and read on open. +pub const MAX_VERSIONS: usize = 100; +/// Maximum version and branch name lengths, in Unicode scalar values. +pub const MAX_VERSION_NAME: usize = 200; +pub const MAX_VERSION_DESCRIPTION: usize = 2000; +/// Maximum bytes of an index and all version snapshots read. +pub const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; +pub const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Maximum images read and maximum combined image bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImageBudget { + pub images: usize, + pub bytes: u64, +} +pub const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; +/// Maximum branch histories kept. +pub const MAX_BRANCHES: usize = 100; +/// The branch index member. +pub const BRANCH_INDEX: &str = "branches/index.json"; +/// Maximum uncompressed bytes read from one native archive in total. +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_UNPACKED: u64 = 6 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_UNPACKED: u64 = 2 * 1024 * 1024 * 1024; + +/// Native-format errors shared with the consumer's document mapping. +#[derive(Debug, PartialEq, Eq)] +pub enum FormatError { + Io(String), + Corrupt(String), + Newer(u32), +} + +impl std::fmt::Display for FormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "{e}"), + Self::Corrupt(e) => write!(f, "not a valid FigCraft file: {e}"), + Self::Newer(v) => write!(f, "this file was saved by a newer FigCraft (format version {v})"), + } + } +} + +impl std::error::Error for FormatError {} + +/// Whether `id` can name a version or branch: 1–64 ASCII letters, digits, `-` and `_`. +pub fn is_valid_version_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Clip text to at most `max` Unicode scalar values. +pub fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} From 68189d1e6e1a926db4eaa2d7843f35174cb75315 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:03:35 +0000 Subject: [PATCH 020/118] feat(figcraft-format): define document expansion policy --- crates/figcraft-format/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index a06b39204..8fddd808d 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -365,6 +365,12 @@ mod seek_archive_tests { /// Largest individual ZIP member read by the native format. pub const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// The document expansion ratio that its compressed size may claim. +pub const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most version snapshots kept on save and read on open. pub const MAX_VERSIONS: usize = 100; /// Maximum version and branch name lengths, in Unicode scalar values. From 5e24ea380e54d4366d9f7aa8c674b3a3833cb472 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:47:44 +0000 Subject: [PATCH 021/118] Avoid duplicated timeline time primitives --- Cargo.lock | 1 + crates/time/src/lib.rs | 161 +++++++- crates/timeline/Cargo.toml | 1 + crates/timeline/src/time.rs | 632 +----------------------------- crates/timeline/src/time/tests.rs | 4 + 5 files changed, 164 insertions(+), 635 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2dbe34bdd..3925c1028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2264,6 +2264,7 @@ dependencies = [ name = "craft-timeline" version = "0.1.0" dependencies = [ + "craft-time", "craft-xml", "proptest", "serde", diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 0d3703060..6cc780cf9 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -35,8 +35,7 @@ impl Tick { /// Seconds to ticks, clamped to [`Tick::MIN`]..=[`Tick::MAX`]. pub fn from_seconds_f64(s: f64) -> Tick { - let t = (s * TICKS_PER_SECOND as f64).round(); - Tick((t as i64).clamp(Tick::MIN.0, Tick::MAX.0)) + Tick((s * TICKS_PER_SECOND as f64).round() as i64) } pub fn seconds(self) -> f64 { @@ -246,7 +245,7 @@ impl TimeRange { } pub fn overlaps(&self, o: &TimeRange) -> bool { - self.start < o.end() && o.start < self.end() + !self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end() } pub fn intersect(&self, o: &TimeRange) -> Option { @@ -316,6 +315,19 @@ impl FrameRate { FrameRate::new((fps * 1000.0).round() as i64, 1000) } + /// Closest standard rate when `fps` is within `tolerance` of it, otherwise the nearest + /// 1/1000 rate. This retains the timeline API's coarse-timebase compatibility behavior. + pub fn from_f64_within(fps: f64, tolerance: f64) -> Self { + let mut best: Option<(f64, FrameRate)> = None; + for rate in Self::COMMON { + let off = (rate.as_f64() - fps).abs(); + if off <= tolerance * rate.as_f64() && best.is_none_or(|(closest, _)| off < closest) { + best = Some((off, rate)); + } + } + best.map_or_else(|| Self::from_f64(fps), |(_, rate)| rate) + } + /// Returns this frame rate if positive, or the default rate ([`FrameRate::FPS_23_976`]). /// /// ### Application Compatibility Note @@ -341,6 +353,11 @@ impl FrameRate { clamp_i64(frame_index(self.sane(), t)) } + /// Frames from `start` to `end`, counted between their frame indices. + pub fn frames_between(self, start: Tick, end: Tick) -> i64 { + self.frame_at(end).saturating_sub(self.frame_at(start)) + } + /// Start tick of frame `f` (ceiling division). pub fn tick_of(self, f: i64) -> Tick { Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) @@ -432,6 +449,9 @@ impl TimeDisplay { pub const ALL: [TimeDisplay; 6] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds]; + /// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format). + pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames]; + pub fn label(self) -> &'static str { match self { TimeDisplay::Timecode => "Timecode", @@ -442,6 +462,32 @@ impl TimeDisplay { TimeDisplay::Seconds => "Seconds", } } + + /// Stable id used by commands and the UI. + pub fn id(self) -> &'static str { + match self { + TimeDisplay::Timecode => "timecode", + TimeDisplay::Frames => "frames", + TimeDisplay::Feet16 => "feet16", + TimeDisplay::Feet35 => "feet35", + TimeDisplay::AudioSamples => "samples", + TimeDisplay::Seconds => "seconds", + } + } + + /// The display with this [`id`](Self::id), ignoring case. + pub fn from_id(id: &str) -> Option { + TimeDisplay::ALL.into_iter().find(|display| display.id().eq_ignore_ascii_case(id)) + } + + /// Frames per foot for the Feet + Frames displays. + pub fn frames_per_foot(self) -> Option { + match self { + TimeDisplay::Feet35 => Some(16), + TimeDisplay::Feet16 => Some(40), + _ => None, + } + } } fn df_params(rate: FrameRate) -> (i64, i64) { @@ -509,35 +555,33 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp match display { TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame), TimeDisplay::Frames => format!("{frame}"), - TimeDisplay::Feet35 | TimeDisplay::Feet16 => { - let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 }; - let neg = frame < 0; - let a = frame.unsigned_abs(); - format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) - } + TimeDisplay::Feet35 | TimeDisplay::Feet16 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)), TimeDisplay::AudioSamples => { let neg = t.0 < 0; let abs_ticks = t.0.unsigned_abs(); let total_secs = abs_ticks / (TICKS_PER_SECOND as u64); let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64); - let rem_samples = (rem_ticks as u128 * sample_rate as u128) / (TICKS_PER_SECOND as u128); + let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate); let h = total_secs / 3600; let m = (total_secs / 60) % 60; let s = total_secs % 60; format!("{}{:02}:{:02}:{:02}:{:05}", if neg { "-" } else { "" }, h, m, s, rem_samples) } - TimeDisplay::Seconds => format!("{:.3}", t.seconds()), + TimeDisplay::Seconds => { + let seconds = format!("{:.3}", t.seconds()); + if seconds == "-0.000" { "0.000".into() } else { seconds } + } } } /// Format a frame count as Feet + Frames (film footage counting). pub fn format_feet_frames(frame: i64, per_foot: i64) -> String { - let pf = per_foot.max(1); + let per_foot = per_foot.max(1); let sign = if frame < 0 { "-" } else { "" }; - let a = frame.unsigned_abs(); - let pf = pf as u64; - let w = if pf > 100 { 3 } else { 2 }; - format!("{sign}{:04}+{:0w$}", a / pf, a % pf) + let frames = frame.unsigned_abs(); + let per_foot = per_foot as u64; + let width = if per_foot > 100 { 3 } else { 2 }; + format!("{sign}{:04}+{:0width$}", frames / per_foot, frames % per_foot) } /// Error from timecode parsing. @@ -561,6 +605,22 @@ impl std::error::Error for ParseError {} /// saturates to `i64::MAX` (or `i64::MIN`) to keep calculations total without errors. /// In contrast, EffectCraft returns `Err(ParseError("timecode too large"))`. pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { + parse_timecode_with_negative_policy(input, rate, drop_frame, current, true) +} + +/// Parse timecode with the relative-negative policy used by the historical timeline API. +/// A leading minus offsets from `current`, including a complete four-field timecode. +pub fn parse_timecode_relative(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { + parse_timecode_with_negative_policy(input, rate, drop_frame, current, false) +} + +fn parse_timecode_with_negative_policy( + input: &str, + rate: FrameRate, + drop_frame: bool, + current: i64, + full_negative_is_absolute: bool, +) -> Result { let s = input.trim(); if s.is_empty() { return Err(ParseError("empty timecode".into())); @@ -615,7 +675,10 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i let base = rate.timecode_base(); let frames = if nums.len() == 1 && sign_char.is_some() { fr - } else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base { + } else if drop_frame && rate.supports_drop_frame() { + let (sec, fr) = (sec.saturating_add(fr / base), fr % base); + let (m, sec) = (m.saturating_add(sec / 60), sec % 60); + let (h, m) = (h.saturating_add(m / 60), m % 60); fields_to_frames(h, m, sec, fr, rate, true) } else { h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) @@ -623,10 +686,11 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i match sign_char { Some('+') => Ok(current.saturating_add(frames)), + Some('-') if full_negative_is_absolute && nums.len() == 4 => Ok(-frames), Some('-') => { // A full 4-field timecode with leading '-' represents an absolute negative timecode (e.g. preroll). - // Single-number or partial relative offsets (e.g. -15 or -1.00) offset from current. - if nums.len() == 4 { Ok(-frames) } else { Ok(current.saturating_sub(frames)) } + // The timeline compatibility path treats every leading minus as an offset instead. + Ok(current.saturating_sub(frames)) } None | Some(_) => Ok(frames), } @@ -671,6 +735,65 @@ pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result Result { + parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, true) +} + +/// Parse text using a [`TimeDisplay`] representation and the historical timeline relative-minus rule. +pub fn parse_time_display_relative(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result { + parse_time_display_with_negative_policy(input, rate, drop_frame, display, current, false) +} + +fn parse_time_display_with_negative_policy( + input: &str, + rate: FrameRate, + drop_frame: bool, + display: TimeDisplay, + current: i64, + full_negative_is_absolute: bool, +) -> Result { + let per_foot = match display { + TimeDisplay::Frames => 1, + TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16), + _ => { + return if full_negative_is_absolute { + parse_timecode(input, rate, drop_frame, current) + } else { + parse_timecode_relative(input, rate, drop_frame, current) + }; + } + }; + let text = input.trim(); + if text.is_empty() { + return Err(ParseError("empty time".into())); + } + let (relative, body) = match text.as_bytes()[0] { + b'+' => (Some(1), &text[1..]), + b'-' => (Some(-1), &text[1..]), + _ => (None, text), + }; + let field = |part: &str| -> Result { + if part.is_empty() { + Ok(0) + } else if !part.bytes().all(|byte| byte.is_ascii_digit()) { + Err(ParseError(format!("not a time: `{input}`"))) + } else { + part.parse::().map_err(|_| ParseError("number too large".into())) + } + }; + let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect(); + let frames = match parts.as_slice() { + [number] => field(number)?, + [feet, frames] if per_foot > 1 => field(feet)?.saturating_mul(per_foot).saturating_add(field(frames)?), + _ => return Err(ParseError(format!("not a time: `{input}`"))), + }; + Ok(match relative { + Some(sign) => current.saturating_add(frames.saturating_mul(sign)), + None => frames, + }) +} + /// Common audio sample rates offered in sequence settings. pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; diff --git a/crates/timeline/Cargo.toml b/crates/timeline/Cargo.toml index 3eca9bc5c..9e73908e4 100644 --- a/crates/timeline/Cargo.toml +++ b/crates/timeline/Cargo.toml @@ -20,6 +20,7 @@ otio = ["dep:serde_json"] [dependencies] serde = { workspace = true, optional = true } +craft-time.workspace = true craft-xml = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } diff --git a/crates/timeline/src/time.rs b/crates/timeline/src/time.rs index f3f12f58d..2db3def5b 100644 --- a/crates/timeline/src/time.rs +++ b/crates/timeline/src/time.rs @@ -1,627 +1,27 @@ -//! Exact media time. +//! Compatibility re-exports for timeline time primitives. //! -//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That -//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50, -//! 59.94, 60, 120 …) and every common audio sample duration (8 k … 192 kHz, including the 44.1 k -//! family), so edits, frame math and audio alignment never drift. -//! -//! Timecode (SMPTE drop/non-drop, frames, feet+frames, samples) is only a *display* of ticks. -//! -//! Every conversion is total: a zero or negative rate (from a damaged file or project) gives -//! zero instead of a division-by-zero panic, and an invalid [`FrameRate`] counts as the default. +//! The implementation lives in `craft-time`; this module keeps the established +//! `craft_timeline::time::*` paths available to downstream users. -use std::fmt; -use std::ops::{Add, AddAssign, Neg, Sub, SubAssign}; +pub use craft_time::{ + FrameRate, ParseError, SAMPLE_RATES, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, fields_to_frames, format_timecode_frames, frames_to_fields, + parse_time_display_relative as parse_time_display, parse_timecode_relative as parse_timecode, +}; -/// Ticks per second. -pub const TICKS_PER_SECOND: i64 = 254_016_000_000; - -/// A point or duration in time, in ticks. -/// Arithmetic saturates at the i64 storage limits; representable results remain exact. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] -pub struct Tick(pub i64); - -impl Tick { - pub const ZERO: Tick = Tick(0); - pub const MAX: Tick = Tick(i64::MAX / 4); - pub const MIN: Tick = Tick(i64::MIN / 4); - - pub fn from_seconds_f64(s: f64) -> Tick { - Tick((s * TICKS_PER_SECOND as f64).round() as i64) - } - pub fn seconds(self) -> f64 { - self.0 as f64 / TICKS_PER_SECOND as f64 - } - /// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000). - pub fn from_units(units: i64, per_second: i64) -> Tick { - Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0))) - } - /// Floor conversion to a count of units at `per_second`. - pub fn to_units_floor(self, per_second: i64) -> i64 { - clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128)) - } - /// Conversion from a rational timestamp `pts * num / den` seconds (container timebases). - pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick { - if den == 0 { - return Tick::ZERO; - } - let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else { - // A product beyond i128 divided by an i64 cannot fit back into i64. - return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX }); - }; - Tick(clamp_i64(t.div_euclid(den as i128))) - } - /// Inverse of [`Tick::from_rational`], floored to the timebase. - pub fn to_rational_floor(self, num: i64, den: i64) -> i64 { - clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0)) - } - /// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up). - /// - /// For finding a sample by its timestamp: containers store timestamps rounded to their - /// timebase (Matroska usually to 1 ms), so a stamp can sit up to half a unit after the true - /// time. Flooring the requested time misses every frame whose stamp was rounded up. - pub fn to_rational_round(self, num: i64, den: i64) -> i64 { - let n = self.0 as i128 * den as i128; - let unit = num as i128 * TICKS_PER_SECOND as i128; - let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) }; - let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 }; - // 0 <= r < unit: round up from the halfway point - clamp_i64(if r * 2 >= unit { q + 1 } else { q }) - } - pub fn abs(self) -> Tick { - Tick(self.0.saturating_abs()) - } - pub fn min(self, o: Tick) -> Tick { - Tick(self.0.min(o.0)) - } - pub fn max(self, o: Tick) -> Tick { - Tick(self.0.max(o.0)) - } - pub fn clamp(self, lo: Tick, hi: Tick) -> Tick { - // max/min rather than `clamp`, which panics when the bounds cross. - Tick(self.0.max(lo.0).min(hi.0)) - } - /// Scale by a rational factor (`num/den`), flooring. - pub fn mul_ratio(self, num: i64, den: i64) -> Tick { - Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0))) - } -} - -impl Add for Tick { - type Output = Tick; - fn add(self, o: Tick) -> Tick { - Tick(self.0.saturating_add(o.0)) - } -} -impl Sub for Tick { - type Output = Tick; - fn sub(self, o: Tick) -> Tick { - Tick(self.0.saturating_sub(o.0)) - } -} -impl Neg for Tick { - type Output = Tick; - fn neg(self) -> Tick { - Tick(self.0.saturating_neg()) - } -} -impl AddAssign for Tick { - fn add_assign(&mut self, o: Tick) { - self.0 = self.0.saturating_add(o.0); - } -} -impl SubAssign for Tick { - fn sub_assign(&mut self, o: Tick) { - self.0 = self.0.saturating_sub(o.0); - } -} - -/// A half-open time range `[start, start + duration)`. -/// Zero or negative durations are empty and never overlap another range. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct TimeRange { - pub start: Tick, - pub duration: Tick, -} - -impl TimeRange { - pub fn new(start: Tick, duration: Tick) -> Self { - Self { start, duration } - } - pub fn from_bounds(start: Tick, end: Tick) -> Self { - Self { start, duration: end - start } - } - pub fn end(&self) -> Tick { - self.start + self.duration - } - pub fn contains(&self, t: Tick) -> bool { - t >= self.start && t < self.end() - } - pub fn overlaps(&self, o: &TimeRange) -> bool { - !self.is_empty() && !o.is_empty() && self.start < o.end() && o.start < self.end() - } - pub fn intersect(&self, o: &TimeRange) -> Option { - let s = self.start.max(o.start); - let e = self.end().min(o.end()); - (e > s).then(|| TimeRange::from_bounds(s, e)) - } - pub fn is_empty(&self) -> bool { - self.duration.0 <= 0 - } -} - -/// A frame rate as an exact rational (`num / den` frames per second). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct FrameRate { - pub num: i64, - pub den: i64, -} - -impl Default for FrameRate { - fn default() -> Self { - FrameRate::FPS_23_976 - } -} - -impl FrameRate { - pub const FPS_23_976: FrameRate = FrameRate { num: 24000, den: 1001 }; - pub const FPS_24: FrameRate = FrameRate { num: 24, den: 1 }; - pub const FPS_25: FrameRate = FrameRate { num: 25, den: 1 }; - pub const FPS_29_97: FrameRate = FrameRate { num: 30000, den: 1001 }; - pub const FPS_30: FrameRate = FrameRate { num: 30, den: 1 }; - pub const FPS_48: FrameRate = FrameRate { num: 48, den: 1 }; - pub const FPS_50: FrameRate = FrameRate { num: 50, den: 1 }; - pub const FPS_59_94: FrameRate = FrameRate { num: 60000, den: 1001 }; - pub const FPS_60: FrameRate = FrameRate { num: 60, den: 1 }; - pub const FPS_119_88: FrameRate = FrameRate { num: 120000, den: 1001 }; - pub const FPS_120: FrameRate = FrameRate { num: 120, den: 1 }; - - /// Rates offered in sequence settings (Premiere's list). - pub const COMMON: [FrameRate; 11] = [ - Self::FPS_23_976, - Self::FPS_24, - Self::FPS_25, - Self::FPS_29_97, - Self::FPS_30, - Self::FPS_48, - Self::FPS_50, - Self::FPS_59_94, - Self::FPS_60, - Self::FPS_119_88, - Self::FPS_120, - ]; - - pub fn new(num: i64, den: i64) -> Self { - let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1)); - FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 } - } - - /// Closest standard rate for a float (e.g. from a container's average rate). - pub fn from_f64(fps: f64) -> Self { - for r in Self::COMMON { - if (r.as_f64() - fps).abs() < 0.005 { - return r; - } - } - FrameRate::new((fps * 1000.0).round() as i64, 1000) - } - - /// Closest standard rate when `fps` is within `tolerance` of it, `tolerance` a fraction of the - /// rate (`0.005` is half a percent), else the nearest 1/1000 rate. A rate a container reports - /// from timestamps rounded to a coarse unit lands slightly off a standard rate: at 90 kHz, - /// whole milliseconds leave a 30 fps clip's average at 30.02 or 29.95 (#1700). - pub fn from_f64_within(fps: f64, tolerance: f64) -> Self { - let mut best: Option<(f64, FrameRate)> = None; - for r in Self::COMMON { - let off = (r.as_f64() - fps).abs(); - if off <= tolerance * r.as_f64() && best.is_none_or(|(closest, _)| off < closest) { - best = Some((off, r)); - } - } - best.map_or_else(|| Self::from_f64(fps), |(_, r)| r) - } - - /// This rate if it is positive, else the default (a damaged file or project can carry a zero - /// or negative rate; every frame computation uses this so it can't divide by zero). - pub fn sane(self) -> FrameRate { - if self.num > 0 && self.den > 0 { self } else { FrameRate::default() } - } - - pub fn as_f64(self) -> f64 { - self.num as f64 / self.den as f64 - } - - /// Exact duration of one frame (rounded down only for exotic rates). - /// Values outside the integer tick range saturate at its limit. - pub fn frame_duration(self) -> Tick { - let r = self.sane(); - Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128)) - } - - /// Index of the frame containing `t` (floor), saturated to the integer frame range. - pub fn frame_at(self, t: Tick) -> i64 { - clamp_i64(frame_index(self.sane(), t)) - } - - /// Frames from `start` to `end`, counted between their frame indices. `frame_at(end - start)` - /// can read one short when a frame is not a whole number of ticks: the ticks between two - /// boundaries can be one fewer than the exact length of those frames. - pub fn frames_between(self, start: Tick, end: Tick) -> i64 { - self.frame_at(end).saturating_sub(self.frame_at(start)) - } - - /// Start tick of frame `f` (ceiling division). - /// - /// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every - /// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks - /// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a - /// fractional remainder). Floor division truncates the remainder, causing each - /// frame boundary to drift backward by one tick until `frame_at` returns `f - 1` - /// instead of `f` — frame stepping then stalls or moves backward. - /// Values outside the integer tick range saturate at its limit. - pub fn tick_of(self, f: i64) -> Tick { - Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) - } - - /// Snap `t` down to a frame boundary. - pub fn snap(self, t: Tick) -> Tick { - let r = self.sane(); - Tick(clamp_i64(frame_boundary(r, frame_index(r, t)))) - } - - /// Snap `t` to the nearest frame boundary. - pub fn snap_nearest(self, t: Tick) -> Tick { - let r = self.sane(); - // Keep the frame index and both boundaries wide until choosing the nearest. Saturating - // an unrepresentable upper boundary first would make it seem closer than it really is. - let f = frame_index(r, t); - let a = frame_boundary(r, f); - let b = frame_boundary(r, f.saturating_add(1)); - let t = i128::from(t.0); - Tick(clamp_i64(if t - a <= b - t { a } else { b })) - } - - /// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976. - pub fn timecode_base(self) -> i64 { - let r = self.sane(); - (r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1) - } - - /// NTSC (x/1001) rates can use drop-frame timecode. - pub fn is_ntsc(self) -> bool { - self.den == 1001 - } - - /// Whether drop-frame counting applies (29.97 / 59.94 / 119.88). - pub fn supports_drop_frame(self) -> bool { - self.is_ntsc() && self.timecode_base() % 30 == 0 - } - - pub fn label(self) -> String { - if self.den == 1 { - format!("{}", self.num) - } else { - let v = self.as_f64(); - let s = format!("{v:.3}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - s.to_string() - } - } -} - -impl fmt::Display for FrameRate { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} fps", self.label()) - } -} - -/// Narrow only after applying the storage bounds, never by a wrapping integer cast. -fn clamp_i64(value: i128) -> i64 { - value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 -} - -/// Callers pass a positive rate. Products of two i64 values fit in i128. -fn frame_index(rate: FrameRate, t: Tick) -> i128 { - (i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den)) -} - -/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot -/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries -/// derived from frame_index remain within i128 (at most one frame away from an i64 tick). -fn frame_boundary(rate: FrameRate, frame: i128) -> i128 { - let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else { - return if frame < 0 { i128::MIN } else { i128::MAX }; - }; - let d = i128::from(rate.num); - // Flooring a fractional boundary puts the tick in the preceding frame. - n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0) -} - -fn gcd(mut a: u64, mut b: u64) -> u64 { - while b != 0 { - (a, b) = (b, a % b); - } - a -} - -/// How time is displayed (Premiere: Timecode, Feet+Frames 16mm/35mm, Frames, Audio Samples). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum TimeDisplay { - #[default] - Timecode, - Frames, - Feet16, - Feet35, - AudioSamples, - Seconds, -} - -impl TimeDisplay { - pub const ALL: [TimeDisplay; 6] = - [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds]; - pub fn label(self) -> &'static str { - match self { - TimeDisplay::Timecode => "Timecode", - TimeDisplay::Frames => "Frames", - TimeDisplay::Feet16 => "Feet + Frames 16mm", - TimeDisplay::Feet35 => "Feet + Frames 35mm", - TimeDisplay::AudioSamples => "Audio Samples", - TimeDisplay::Seconds => "Seconds", - } - } -} - -impl TimeDisplay { - /// The video display formats (Sequence Settings ▸ Display Format, the burn-in's Time Format). - pub const VIDEO: [TimeDisplay; 4] = [TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames]; - - /// Stable id used by commands and the UI: `timecode`, `feet35`, `feet16`, `frames`, `samples`, `seconds`. - pub fn id(self) -> &'static str { - match self { - TimeDisplay::Timecode => "timecode", - TimeDisplay::Frames => "frames", - TimeDisplay::Feet16 => "feet16", - TimeDisplay::Feet35 => "feet35", - TimeDisplay::AudioSamples => "samples", - TimeDisplay::Seconds => "seconds", - } - } - - /// The display with this [`id`](Self::id), ignoring case. - pub fn from_id(id: &str) -> Option { - TimeDisplay::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(id)) - } - - /// Frames per foot of film for the Feet + Frames displays: 16 for 35mm 4-perf, 40 for 16mm. - pub fn frames_per_foot(self) -> Option { - match self { - TimeDisplay::Feet35 => Some(16), - TimeDisplay::Feet16 => Some(40), - _ => None, - } - } -} - -/// Format a frame count as film feet + frames (`F+FF`, e.g. `2+08`; negative: `-2+08`). -/// `frames_per_foot` below 1 is treated as 1 so a bad value never divides by zero. +/// Format a timeline frame count as film feet + frames, retaining its established compact display. pub fn format_feet_frames(frame: i64, frames_per_foot: i64) -> String { - let per_ft = frames_per_foot.max(1).unsigned_abs(); - let a = frame.unsigned_abs(); - format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, a / per_ft, a % per_ft) + let per_foot = frames_per_foot.max(1).unsigned_abs(); + let frames = frame.unsigned_abs(); + format!("{}{}+{:02}", if frame < 0 { "-" } else { "" }, frames / per_foot, frames % per_foot) } -/// Drop-frame parameters: frames dropped per minute and nominal base. -fn df_params(rate: FrameRate) -> (i64, i64) { - let base = rate.timecode_base(); - (base / 15, base) // 30 → 2, 60 → 4, 120 → 8 -} - -/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`. -pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) { - let neg = frame < 0; - let mut n = i128::from(frame).abs(); - let (drop, base) = df_params(rate); - let (drop, base) = (i128::from(drop), i128::from(base)); - if drop_frame && rate.supports_drop_frame() { - let per_min = base * 60 - drop; - let per_10 = per_min * 10 + drop; - let d = n / per_10; - let m = n % per_10; - n = n.saturating_add(drop * 9 * d); - if m > drop { - n = n.saturating_add(drop * ((m - drop) / per_min)); - } +/// Format time using the timeline's historical compact Feet + Frames display. +pub fn format_time(tick: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { + if let TimeDisplay::Feet35 | TimeDisplay::Feet16 = display { + return format_feet_frames(rate.frame_at(tick), display.frames_per_foot().unwrap_or(16)); } - let f = n % base; - let s = (n / base) % 60; - let mi = (n / (base * 60)) % 60; - let h = n / (base * 3600); - (neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f)) + craft_time::format_time(tick, rate, drop_frame, display, sample_rate) } -/// Convert SMPTE fields back to a frame count. -pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 { - let (drop, base) = df_params(rate); - let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s); - // Nominal drop-frame labels can exceed i64 even when the final frame count fits. - let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f)); - if drop_frame && rate.supports_drop_frame() { - let total_min = i128::from(h) * 60 + i128::from(m); - n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10)); - } - clamp_i64(n) -} - -/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame). -pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> String { - let df = drop_frame && rate.supports_drop_frame(); - let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); - let sep = if df { ';' } else { ':' }; - let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; - format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) -} - -/// Format a tick according to a display mode. -pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { - let frame = rate.frame_at(t); - match display { - TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame), - TimeDisplay::Frames => format!("{frame}"), - TimeDisplay::Feet35 | TimeDisplay::Feet16 => format_feet_frames(frame, display.frames_per_foot().unwrap_or(16)), - TimeDisplay::AudioSamples => { - let neg = t.0 < 0; - let a = t.0.unsigned_abs(); - let tps = TICKS_PER_SECOND.unsigned_abs(); - let secs = a / tps; - let rem = Tick((a % tps) as i64).to_units_floor(sample_rate); - format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60) - } - TimeDisplay::Seconds => { - let s = format!("{:.3}", t.seconds()); - // A tiny negative time rounds to zero but keeps its sign: drop it. - if s == "-0.000" { "0.000".to_string() } else { s } - } - } -} - -/// Error from [`parse_timecode`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseError(pub String); - -impl fmt::Display for ParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} -impl std::error::Error for ParseError {} - -/// Parse user-typed timecode into a frame count, the way Premiere's timecode fields do: -/// - `01:02:03:04`, `01;02;03;04`, `1.2.3.4` (any of `:;.,` separators), -/// - bare digits are read right-aligned as `HHMMSSFF` (`1000` = 00:00:10:00), -/// - a leading `+`/`-` makes the value relative to `current` (`+15` = 15 frames later, `-1.00` = 1 s earlier). -pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result { - let s = input.trim(); - if s.is_empty() { - return Err(ParseError("empty timecode".into())); - } - let (rel, body) = match s.as_bytes()[0] { - b'+' => (Some(1), &s[1..]), - b'-' => (Some(-1), &s[1..]), - _ => (None, s), - }; - let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); - let nums: Vec = if parts.len() == 1 { - let digits = parts[0]; - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(ParseError(format!("not a timecode: `{input}`"))); - } - // Right-aligned pairs: "12345" -> [1, 23, 45]; relative bare numbers are frames. - if rel.is_some() { - vec![digits.parse().map_err(|_| ParseError("number too large".into()))?] - } else { - let mut v = Vec::new(); - let b = digits.as_bytes(); - let mut end = b.len(); - while end > 0 { - let start = end.saturating_sub(2); - v.push(digits[start..end].parse::().unwrap_or(0)); - end = start; - } - v.reverse(); - v - } - } else { - parts - .iter() - .map(|p| { - if p.is_empty() { - Ok(0) - } else if !p.bytes().all(|b| b.is_ascii_digit()) { - Err(ParseError(format!("bad field `{p}`"))) - } else { - p.parse::().map_err(|_| ParseError(format!("bad field `{p}`"))) - } - }) - .collect::>()? - }; - if nums.len() > 4 { - return Err(ParseError("too many timecode fields".into())); - } - let mut f4 = [0i64; 4]; - let off = 4 - nums.len(); - f4[off..].copy_from_slice(&nums); - let [h, m, sec, fr] = f4; - // Overflowing fields (e.g. 90 frames) are allowed, as in Premiere. - let base = rate.timecode_base(); - let frames = if nums.len() == 1 && rel.is_some() { - fr - } else if drop_frame && rate.supports_drop_frame() { - // overflowing fields carry into the next one first, then drop-frame counting applies - let (sec, fr) = (sec.saturating_add(fr / base), fr % base); - let (m, sec) = (m.saturating_add(sec / 60), sec % 60); - let (h, m) = (h.saturating_add(m / 60), m % 60); - fields_to_frames(h, m, sec, fr, rate, true) - } else { - // Saturating: typed or agent-sent timecodes can have absurdly large fields. - h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(sec).saturating_mul(base).saturating_add(fr) - }; - Ok(match rel { - Some(sign) => current.saturating_add(frames.saturating_mul(sign)), - None => frames, - }) -} - -/// Parse typed text in the given display format into a frame count. -/// -/// - [`TimeDisplay::Frames`]: a plain frame count (`240`). -/// - [`TimeDisplay::Feet35`] / [`TimeDisplay::Feet16`]: `F+FF` (`2+08`; `.`, `:`, `;` and `,` also -/// separate); a bare number is a frame count. A frames field past the foot rolls over -/// (`1+20` at 35mm is 36 frames), as the timecode parser does. -/// - A leading `+` / `-` makes the value relative to `current` (a bare relative number is frames). -/// - Every other display parses as timecode ([`parse_timecode`]). -pub fn parse_time_display(input: &str, rate: FrameRate, drop_frame: bool, display: TimeDisplay, current: i64) -> Result { - let per_ft = match display { - TimeDisplay::Frames => 1, - TimeDisplay::Feet35 | TimeDisplay::Feet16 => display.frames_per_foot().unwrap_or(16), - _ => return parse_timecode(input, rate, drop_frame, current), - }; - let s = input.trim(); - if s.is_empty() { - return Err(ParseError("empty time".into())); - } - let (rel, body) = match s.as_bytes()[0] { - b'+' => (Some(1), &s[1..]), - b'-' => (Some(-1), &s[1..]), - _ => (None, s), - }; - let field = |p: &str| -> Result { - if p.is_empty() { - Ok(0) - } else if !p.bytes().all(|b| b.is_ascii_digit()) { - Err(ParseError(format!("not a time: `{input}`"))) - } else { - p.parse::().map_err(|_| ParseError("number too large".into())) - } - }; - let parts: Vec<&str> = body.split(['+', ':', ';', '.', ',']).collect(); - let frames = match parts.as_slice() { - [n] => field(n)?, - [feet, fr] if per_ft > 1 => field(feet)?.saturating_mul(per_ft).saturating_add(field(fr)?), - _ => return Err(ParseError(format!("not a time: `{input}`"))), - }; - Ok(match rel { - Some(sign) => current.saturating_add(frames.saturating_mul(sign)), - None => frames, - }) -} - -/// Common audio sample rates offered in sequence settings. -pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; - #[cfg(test)] mod tests; diff --git a/crates/timeline/src/time/tests.rs b/crates/timeline/src/time/tests.rs index e6aada244..40bea7624 100644 --- a/crates/timeline/src/time/tests.rs +++ b/crates/timeline/src/time/tests.rs @@ -1,6 +1,10 @@ use super::*; use proptest::prelude::*; +fn clamp_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + #[test] fn empty_time_ranges_never_overlap() { let clip = TimeRange::new(Tick(0), Tick(10)); From f16d99f71c417d1f9247ae17ec53f4a66f6bfb82 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:57:51 +0000 Subject: [PATCH 022/118] fix time parsing and frame boundary edge cases --- crates/time/README.md | 2 +- crates/time/src/lib.rs | 23 ++++++++++++++- crates/time/src/soundcraft/format.rs | 2 +- crates/time/src/soundcraft/grid.rs | 40 ++++++++++++++++++++++---- crates/time/src/soundcraft/mod.rs | 28 +++++++++++++++++- crates/time/src/soundcraft/timecode.rs | 23 +++++++++++---- 6 files changed, 103 insertions(+), 15 deletions(-) diff --git a/crates/time/README.md b/crates/time/README.md index 0f62fb3b1..9c85b0219 100644 --- a/crates/time/README.md +++ b/crates/time/README.md @@ -35,7 +35,7 @@ assert_eq!(audio_sample, one_sec); Frame rates are represented as exact fractions (`num / den`): ```rust -use craft_time::FrameRate; +use craft_time::{FrameRate, Tick}; // Standard NTSC and PAL rates let ntsc_24 = FrameRate::FPS_23_976; // 24000 / 1001 diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 6cc780cf9..4a3af58df 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -561,7 +561,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp let abs_ticks = t.0.unsigned_abs(); let total_secs = abs_ticks / (TICKS_PER_SECOND as u64); let rem_ticks = abs_ticks % (TICKS_PER_SECOND as u64); - let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate); + let rem_samples = Tick(rem_ticks as i64).to_units_floor(sample_rate.max(0)); let h = total_secs / 3600; let m = (total_secs / 60) % 60; let s = total_secs % 60; @@ -625,11 +625,22 @@ fn parse_timecode_with_negative_policy( if s.is_empty() { return Err(ParseError("empty timecode".into())); } + if s.len() > 80 { + return Err(ParseError("timecode is too long".into())); + } let (sign_char, body) = match s.as_bytes()[0] { b'+' => (Some('+'), &s[1..]), b'-' => (Some('-'), &s[1..]), _ => (None, s), }; + // Four fields is the complete timecode grammar. Check limits before allocating split vectors. + let separators = body.bytes().filter(|b| matches!(b, b':' | b';' | b'.' | b',')).count(); + if separators > 3 { + return Err(ParseError("too many timecode fields".into())); + } + if separators == 0 && body.len() > if sign_char.is_some() { 19 } else { 8 } { + return Err(ParseError("timecode is too long".into())); + } let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect(); let nums: Vec = if parts.len() == 1 { let digits = parts[0]; @@ -824,6 +835,15 @@ mod tests { } } + #[test] + fn timecode_parser_rejects_oversized_inputs_before_field_collection() { + let too_many_fields = ":".repeat(100_000); + assert!(parse_timecode(&too_many_fields, FrameRate::FPS_30, false, 0).is_err()); + let compact = "1".repeat(9); + assert!(parse_timecode(&compact, FrameRate::FPS_30, false, 0).is_err()); + assert!(parse_timecode_relative("+12345678901234567890", FrameRate::FPS_30, false, 0).is_err()); + } + #[test] fn test_broadcast_frame_rates() { for r in FrameRate::COMMON { @@ -858,6 +878,7 @@ mod tests { assert_eq!(format_time(neg_complex, r, false, TimeDisplay::AudioSamples, 48_000), "-01:01:01:00000"); let zero = Tick::ZERO; + assert_eq!(format_time(Tick(2), r, false, TimeDisplay::AudioSamples, -1), "00:00:00:00000"); assert_eq!(format_time(zero, r, false, TimeDisplay::AudioSamples, 48_000), "00:00:00:00000"); } diff --git a/crates/time/src/soundcraft/format.rs b/crates/time/src/soundcraft/format.rs index 11b588c3f..b16511006 100644 --- a/crates/time/src/soundcraft/format.rs +++ b/crates/time/src/soundcraft/format.rs @@ -76,7 +76,7 @@ pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRa let t0 = map.samples_to_ticks(start, sr); let t1 = map.samples_to_ticks(start.saturating_add(len), sr); let m = map.meter_at_tick(t0); - let d = (t1 - t0).max(0); + let d = t1.saturating_sub(t0).max(0); let bars = d / m.ticks_per_bar().max(1); let rem = d % m.ticks_per_bar().max(1); format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) diff --git a/crates/time/src/soundcraft/grid.rs b/crates/time/src/soundcraft/grid.rs index 5f32ca6d8..2d40a454b 100644 --- a/crates/time/src/soundcraft/grid.rs +++ b/crates/time/src/soundcraft/grid.rs @@ -430,7 +430,7 @@ impl GridValue { // always four quarter notes and therefore keeps a continuous tick origin. let bb = map.bar_beat_at_tick(t); let bar_tick = map.bar_start_tick(bb.bar); - let origin = if value == NoteValue::Whole { 0 } else { bar_tick }; + let origin = if value == NoteValue::Whole || (value == NoteValue::Bar && dotted) { 0 } else { bar_tick }; let step = Self::note_ticks(value, dotted, triplet, map, origin); let k = (t - origin).div_euclid(step); map.tick_to_samples(origin + k * step, sr) @@ -547,7 +547,14 @@ impl GridValue { } let step = self.step_samples(line, sr, map, rate); let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate); - if candidate > line { candidate } else { line.saturating_add(step) } + let next = if candidate > line { candidate } else { line.saturating_add(step) }; + if matches!(self, GridValue::Note { value: NoteValue::Bar, .. }) { + return next; + } + let tick = map.samples_to_ticks(line, sr); + let bar = map.bar_beat_at_tick(tick).bar; + let next_bar = map.tick_to_samples(map.bar_start_tick(bar.saturating_add(1)), sr); + if next_bar > line { next.min(next_bar) } else { next } } /// All grid lines within `[start, end)`, capped at `max` lines. @@ -681,6 +688,27 @@ mod tests { assert_eq!(note(NoteValue::Half).lines(0, 150_000, sr, &map, rate, 10), [0, 48_000, 72_000, 120_000, 144_000]); } + #[test] + fn dotted_bar_grid_keeps_its_spacing_across_bars() { + let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25); + let grid = GridValue::Note { value: NoteValue::Bar, dotted: true, triplet: false }; + assert_eq!(grid.line_at_or_before(143_999, sr, &map, rate), 0); + assert_eq!(grid.line_at_or_before(144_000, sr, &map, rate), 144_000); + assert_eq!(grid.next_line(0, sr, &map, rate), 144_000); + } + + #[test] + fn dotted_note_does_not_skip_a_meter_change_boundary() { + let sr = SampleRate::HZ_48000; + let mut map = TempoMap::default(); + map.set_meter(TICKS_PER_QUARTER * 4, 2, 4).unwrap(); + let rate = FrameRate::Fps25; + let grid = GridValue::Note { value: NoteValue::Half, dotted: true, triplet: false }; + assert_eq!(grid.next_line(72_000, sr, &map, rate), 96_000); + assert!(grid.lines(72_000, 120_000, sr, &map, rate, 10).contains(&96_000)); + assert_eq!(grid.snap(90_000, sr, &map, rate), 96_000); + } + #[test] fn seconds_grid_lines_land_on_whole_seconds() { let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30); @@ -745,7 +773,8 @@ mod tests { let frame = GridValue::drop_second_frame(seconds, rate); let line = rate.samples_at_frame(frame, sr); assert_eq!(grid.line_at_or_before(line, sr, &map, rate), line, "{rate:?} second {seconds}"); - assert_eq!(grid.line_at_or_before(-line, sr, &map, rate), -line, "{rate:?} second -{seconds}"); + let negative_line = rate.samples_at_frame(-frame, sr); + assert_eq!(grid.line_at_or_before(negative_line, sr, &map, rate), negative_line, "{rate:?} second -{seconds}"); for offset in -2..=rate.nominal() + 2 { let position = rate.samples_at_frame(frame.saturating_add(offset), sr); let before = grid.line_at_or_before(position, sr, &map, rate); @@ -761,8 +790,9 @@ mod tests { let boundary = rate.samples_at_frame(minute_frame, sr); assert_eq!(grid.line_at_or_before(boundary, sr, &map, rate), boundary, "{rate:?} positive minute boundary"); assert_eq!(grid.snap(boundary, sr, &map, rate), boundary, "{rate:?} positive minute snap"); - assert_eq!(grid.line_at_or_before(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute boundary"); - assert_eq!(grid.snap(-boundary, sr, &map, rate), -boundary, "{rate:?} negative minute snap"); + let negative_boundary = rate.samples_at_frame(-minute_frame, sr); + assert_eq!(grid.line_at_or_before(negative_boundary, sr, &map, rate), negative_boundary, "{rate:?} negative minute boundary"); + assert_eq!(grid.snap(negative_boundary, sr, &map, rate), negative_boundary, "{rate:?} negative minute snap"); } } diff --git a/crates/time/src/soundcraft/mod.rs b/crates/time/src/soundcraft/mod.rs index bd4df3aa9..300909003 100644 --- a/crates/time/src/soundcraft/mod.rs +++ b/crates/time/src/soundcraft/mod.rs @@ -38,8 +38,16 @@ pub enum TimeError { /// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "u32")] pub struct SampleRate(u32); +impl TryFrom for SampleRate { + type Error = TimeError; + fn try_from(hz: u32) -> Result { + Self::new(hz) + } +} + impl SampleRate { pub const HZ_44100: SampleRate = SampleRate(44_100); pub const HZ_48000: SampleRate = SampleRate(48_000); @@ -107,7 +115,7 @@ impl Range { s >= self.start && s < self.end } pub fn overlaps(&self, o: &Range) -> bool { - self.start < o.end && o.start < self.end + !self.is_empty() && !o.is_empty() && self.start < o.end && o.start < self.end } pub fn intersect(&self, o: &Range) -> Option { let s = self.start.max(o.start); @@ -123,6 +131,24 @@ impl Range { mod tests { use super::*; + #[test] + fn sample_rate_serde_validates_nonzero_bounds() { + assert!(serde_json::from_str::("0").is_err()); + assert!(serde_json::from_str::("7999").is_err()); + let rate = SampleRate::new(48_000).unwrap(); + assert_eq!(serde_json::to_string(&rate).unwrap(), "48000"); + assert_eq!(serde_json::from_str::("48000").unwrap(), rate); + } + + #[test] + fn empty_ranges_never_overlap() { + let point = Range::point(5); + let span = Range::new(0, 10); + assert!(!point.overlaps(&span)); + assert!(!span.overlaps(&point)); + assert!(Range::new(0, 5).overlaps(&span)); + } + #[test] fn sample_rate_bounds() { assert!(SampleRate::new(48_000).is_ok()); diff --git a/crates/time/src/soundcraft/timecode.rs b/crates/time/src/soundcraft/timecode.rs index 03eb1cc94..d09458e5c 100644 --- a/crates/time/src/soundcraft/timecode.rs +++ b/crates/time/src/soundcraft/timecode.rs @@ -108,9 +108,12 @@ impl FrameRate { if n == 0 { return 0; } - // Round half away from zero so negative frames mirror positive ones. - let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); - let v = if f < 0 { -mag } else { mag }; + // Use the first integer sample whose floored frame lookup is `f`; nearest rounding + // can put fractional-rate frame starts just before their own lookup interval. + let numerator = i128::from(f) * i128::from(sr.hz()) * i128::from(d); + let denominator = i128::from(n); + let floor = numerator.div_euclid(denominator); + let v = floor + i128::from(numerator.rem_euclid(denominator) != 0); i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) } @@ -194,6 +197,12 @@ impl Timecode { Some(r) => (true, r), None => (false, t), }; + if t.len() > 80 { + return Err(TimeError::Parse("timecode is too long".into(), "timecode")); + } + if t.bytes().filter(|b| matches!(b, b':' | b';' | b'.')).count() > 3 { + return Err(TimeError::Parse(text.to_string(), "timecode")); + } let parts: Vec<&str> = t.split([':', ';', '.']).collect(); if parts.is_empty() || parts.len() > 4 { return Err(TimeError::Parse(text.to_string(), "timecode")); @@ -232,15 +241,16 @@ mod tests { } #[test] - fn negative_timecode_rounds_symmetrically() { + fn fractional_frame_starts_match_floored_lookup_on_both_sides_of_zero() { let sr = SampleRate::HZ_48000; let tc = Timecode::parse("-00:00:01:00").unwrap(); assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000); for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] { - for f in [1i64, 7, 25, 1799, 107_892] { - assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}"); + for f in [-107_892i64, -25, -7, -1, 0, 1, 2, 7, 25, 1799, 107_892] { + assert_eq!(r.frames_at(r.samples_at_frame(f, sr), sr), f, "{r:?} frame {f}"); } } + assert_eq!(FrameRate::Fps29_97.frames_at(FrameRate::Fps29_97.samples_at_frame(2, sr), sr), 2); } #[test] @@ -299,6 +309,7 @@ mod tests { assert_eq!((tc.seconds, tc.frames), (3, 4)); assert!(Timecode::parse("a:b").is_err()); assert!(Timecode::parse("1:2:3:4:5").is_err()); + assert!(Timecode::parse(&format!("{}:", "1".repeat(100_000))).is_err()); } #[test] From 07bac57ec2bfe1633f1b2fbb8047cdb3d2096729 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:58:41 +0000 Subject: [PATCH 023/118] add shared FigCraft history and branch schemas --- crates/figcraft-format/src/lib.rs | 146 +++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 8fddd808d..5a53ea614 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -2,13 +2,129 @@ //! The neutral JSON header and version contract for FigCraft's native project format. //! App-specific document fields remain a generic flattened payload. use serde::de::{IgnoredAny, MapAccess, Visitor}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; pub const EXTENSION: &str = "figcraft"; pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; +fn is_false(value: &bool) -> bool { + !value +} + +/// One native version-history index entry. Invalid optional values decode to the same defaults as +/// the historical lenient reader; the app applies document-specific validity and issue reporting. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct VersionEntry { + pub id: String, + pub name: String, + pub description: String, + pub time: u64, + #[serde(skip_serializing_if = "is_false")] + pub auto: bool, + pub images: Vec, +} + +impl<'de> Deserialize<'de> for VersionEntry { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let string = |key: &str| value.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + let images = value.get("images").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + Ok(Self { + id: string("id"), + name: string("name"), + description: string("description"), + time: value.get("time").and_then(Value::as_u64).unwrap_or(0), + auto: value.get("auto").and_then(Value::as_bool).unwrap_or(false), + images, + }) + } +} + +/// Native version-history index, oldest first. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct VersionIndex { + pub versions: Vec, +} + +impl<'de> Deserialize<'de> for VersionIndex { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let versions = value + .get("versions") + .and_then(Value::as_array) + .map(|entries| entries.iter().cloned().map(serde_json::from_value).collect::, _>>()) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(Self { versions }) + } +} + +/// State stored on a native branch entry. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BranchState { + #[default] + Active, + Merged, + Archived, +} + +/// One native branch index entry. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchEntry { + pub id: String, + pub name: String, + pub time: u64, + pub client: u32, + pub state: BranchState, + pub base_images: Vec, + pub head_images: Vec, +} + +impl<'de> Deserialize<'de> for BranchEntry { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let string = |key: &str| value.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + let images = + |key: &str| value.get(key).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + let state = value.get("state").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let client = value.get("client").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + Ok(Self { + id: string("id"), + name: string("name"), + time: value.get("time").and_then(Value::as_u64).unwrap_or(0), + client, + state, + base_images: images("baseImages"), + head_images: images("headImages"), + }) + } +} + +/// Native branch index. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct BranchIndex { + pub branches: Vec, +} + +impl<'de> Deserialize<'de> for BranchIndex { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let branches = value + .get("branches") + .and_then(Value::as_array) + .map(|entries| entries.iter().cloned().map(serde_json::from_value).collect::, _>>()) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(Self { branches }) + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Header { pub version: Option, @@ -92,6 +208,34 @@ mod tests { assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()])); } + + #[test] + fn version_index_preserves_lenient_defaults_and_writes_compact_auto_flag() { + let index: VersionIndex = + serde_json::from_str(r#"{"extra":true,"versions":[{"id":"v1","time":7,"auto":false,"images":["a",3]},{}]}"#).unwrap(); + assert_eq!(index.versions.len(), 2); + assert_eq!(index.versions[0].images, vec!["a"]); + assert_eq!(index.versions[1], VersionEntry::default()); + let encoded = serde_json::to_value(&index).unwrap(); + assert!(encoded["versions"][0].get("auto").is_none()); + assert_eq!(encoded["versions"][0]["time"], 7); + } + + #[test] + fn branch_index_uses_native_camel_case_and_tolerates_invalid_optional_fields() { + let index: BranchIndex = serde_json::from_str( + r#"{"branches":[{"id":"b1","client":4294967296,"state":"unknown","baseImages":["a",false],"headImages":["b"]},{}]}"#, + ) + .unwrap(); + let first = &index.branches[0]; + assert_eq!(first.client, 0); + assert_eq!(first.state, BranchState::Active); + assert_eq!(first.base_images, vec!["a"]); + assert_eq!(first.head_images, vec!["b"]); + let encoded = serde_json::to_value(&index).unwrap(); + assert_eq!(encoded["branches"][0]["baseImages"][0], "a"); + assert!(encoded["branches"][0].get("base_images").is_none()); + } #[test] fn newer_version_is_reported_before_document_decode() { assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); From a75b9df240d3e2eb98898fcbf99403ae5a1c73bb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 08:00:56 +0000 Subject: [PATCH 024/118] share FigCraft document expansion policy --- crates/figcraft-format/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 5a53ea614..f086d4728 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -236,6 +236,16 @@ mod tests { assert_eq!(encoded["branches"][0]["baseImages"][0], "a"); assert!(encoded["branches"][0].get("base_images").is_none()); } + + #[test] + fn document_limit_keeps_compressible_files_and_caps_expansion() { + const MB: u64 = 1024 * 1024; + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + assert_eq!(document_limit(MAX_DOCUMENT, MAX_DOCUMENT), MAX_DOCUMENT); + assert_eq!(document_limit(244_362_923, 260 * MB), 244_362_923 * DOCUMENT_RATIO); + } #[test] fn newer_version_is_reported_before_document_decode() { assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); @@ -515,6 +525,13 @@ pub const DOCUMENT_RATIO: u64 = 16; pub const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; #[cfg(target_pointer_width = "32")] pub const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; + +/// Upper bound for the expanded document member based on both the archive and member policy. +/// The floor preserves support for large but highly compressible documents; the ceiling prevents +/// a tiny archive from claiming an unbounded expansion. +pub fn document_limit(packed: u64, archive_bytes: u64) -> u64 { + packed.min(archive_bytes).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} /// Most version snapshots kept on save and read on open. pub const MAX_VERSIONS: usize = 100; /// Maximum version and branch name lengths, in Unicode scalar values. From d249336e76465ccbccf8b6570a9ed10a27b2cb1d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 08:10:02 +0000 Subject: [PATCH 025/118] move native archive read budgets into shared format --- crates/figcraft-format/src/lib.rs | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index f086d4728..c7e7261aa 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -246,6 +246,23 @@ mod tests { assert_eq!(document_limit(MAX_DOCUMENT, MAX_DOCUMENT), MAX_DOCUMENT); assert_eq!(document_limit(244_362_923, 260 * MB), 244_362_923 * DOCUMENT_RATIO); } + + #[test] + fn read_budget_enforces_member_and_archive_totals() { + let bytes = write_archive([ + Member { name: "one", bytes: b"12345678", compression: Compression::Stored }, + Member { name: "two", bytes: b"abcdefgh", compression: Compression::Stored }, + ]) + .unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); + let mut budget = ReadBudget::new(10); + assert_eq!(budget.read(&mut archive, "one", 16).unwrap(), b"12345678"); + assert!(budget.read(&mut archive, "two", 16).unwrap_err().to_string().contains("contents together pass 10 bytes")); + + let mut member_budget = ReadBudget::new(100); + assert!(member_budget.read(&mut archive, "two", 4).unwrap_err().to_string().contains("two is too large")); + assert_eq!(member_budget.read(&mut archive, "two", 16).unwrap(), b"abcdefgh"); + } #[test] fn newer_version_is_reported_before_document_decode() { assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); @@ -577,6 +594,48 @@ impl std::fmt::Display for FormatError { impl std::error::Error for FormatError {} +/// Tracks the total bytes unpacked from one native archive across its document, images, library, +/// history, and branch members. +#[derive(Clone, Debug)] +pub struct ReadBudget { + left: u64, + total: u64, +} + +impl ReadBudget { + pub fn new(total: u64) -> Self { + Self { left: total, total } + } + + fn passed(&self) -> String { + match self.total / (1024 * 1024) { + 0 => format!("too large: the file's contents together pass {} bytes", self.total), + mb => format!("too large: the file's contents together pass {mb} MB"), + } + } + + /// Read one native member within its own size limit and the remaining archive-wide budget. + /// A failed read that consumed a member is charged at its declared size. + pub fn read(&mut self, archive: &mut impl NativeArchive, name: &str, limit: u64) -> Result, FormatError> { + let declared = archive.declared_size(name); + if declared.is_some_and(|size| size > self.left && size <= limit) { + return Err(FormatError::Corrupt(format!("{name}: {}", self.passed()))); + } + let cap = limit.min(self.left); + let size = declared.unwrap_or(0); + let result = match archive.read_member(name, cap) { + Ok(Some(bytes)) => Ok(bytes), + Ok(None) => Err(FormatError::Corrupt(format!("{name}: not found"))), + Err(error) if error.is_limit() => Err(FormatError::Corrupt(format!("{name} is too large"))), + Err(error) => Err(FormatError::Corrupt(format!("{name}: {error}"))), + }; + if size <= cap { + self.left = self.left.saturating_sub(result.as_ref().map_or(size, |bytes| bytes.len() as u64)); + } + result + } +} + /// Whether `id` can name a version or branch: 1–64 ASCII letters, digits, `-` and `_`. pub fn is_valid_version_id(id: &str) -> bool { (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') From 1592923e6db6ed1458f7a4f2982b6e9db4057224 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 08:15:45 +0000 Subject: [PATCH 026/118] expose archive budget remaining diagnostics --- crates/figcraft-format/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index c7e7261aa..3dc8e3a32 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -257,6 +257,8 @@ mod tests { let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); let mut budget = ReadBudget::new(10); assert_eq!(budget.read(&mut archive, "one", 16).unwrap(), b"12345678"); + assert!(budget.exceeds_remaining(&mut archive, "two")); + assert!(budget.exhausted_message().contains("contents together pass 10 bytes")); assert!(budget.read(&mut archive, "two", 16).unwrap_err().to_string().contains("contents together pass 10 bytes")); let mut member_budget = ReadBudget::new(100); @@ -614,6 +616,16 @@ impl ReadBudget { } } + /// Whether the member's declared uncompressed size exceeds what remains in this budget. + pub fn exceeds_remaining(&self, archive: &mut impl NativeArchive, name: &str) -> bool { + archive.declared_size(name).is_some_and(|size| size > self.left) + } + + /// Human-readable reason for leaving out a member after the archive-wide limit is spent. + pub fn exhausted_message(&self) -> String { + self.passed() + } + /// Read one native member within its own size limit and the remaining archive-wide budget. /// A failed read that consumed a member is charged at its declared size. pub fn read(&mut self, archive: &mut impl NativeArchive, name: &str, limit: u64) -> Result, FormatError> { From 370f6714edb1153dc9497afd79bd421bc0d6841f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 03:34:41 -0500 Subject: [PATCH 027/118] craft-stencilcraft-format: support typed document fields --- crates/stencilcraft-format/src/lib.rs | 140 ++++++++++++++++++-------- 1 file changed, 99 insertions(+), 41 deletions(-) diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 709bf7ac7..9a0df0093 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -16,10 +16,9 @@ //! ``` //! //! [`DocumentFields`] owns the JSON field names, required and defaulted fields, and the -//! flattening rule. Its values are JSON because the consuming app owns the document model, -//! domain validation, and conversion between that model and the wire fields. The crate does not -//! open files or set a file-size policy; callers handle bounded file I/O. [`File::read_from`] -//! lets callers parse from a reader without first buffering source bytes in a string. +//! flattening rule. Its generic form lets an application use its typed domain values directly, +//! while keeping the wire schema and JSON encoding here. The crate does not open files or set a +//! file-size policy; callers handle bounded file I/O. [`File::read_from`] parses from a reader. use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; @@ -37,62 +36,96 @@ pub const FORMAT_VERSION: u64 = 1; pub const HEADER_KEY: &str = "stencilcraft"; /// Wire fields in the flattened native document object. +/// +/// The type parameters are the consuming app's typed model values. Field names, required fields, +/// version-1 defaults, and omission of default canvas values remain owned by this crate. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DocumentFields { +#[serde(bound( + serialize = "Board: Serialize, Kerf: Serialize, Layers: Serialize, Image: Serialize, BoardSetup: Serialize + Default + PartialEq, Pages: Serialize + Default + PartialEq, Guides: Serialize + Default + PartialEq, Grid: Serialize + Default + PartialEq, Snap: Serialize + Default + PartialEq, Groups: Serialize + Default + PartialEq, Meta: Serialize + Default + PartialEq", + deserialize = "Board: Deserialize<'de>, Kerf: Deserialize<'de>, Layers: Deserialize<'de>, Image: Deserialize<'de>, BoardSetup: Deserialize<'de> + Default, Pages: Deserialize<'de> + Default, Guides: Deserialize<'de> + Default, Grid: Deserialize<'de> + Default, Snap: Deserialize<'de> + Default, Groups: Deserialize<'de> + Default, Meta: Deserialize<'de> + Default" +))] +pub struct DocumentFields< + Board = Value, + Kerf = Value, + Layers = Value, + Image = Value, + BoardSetup = Option, + Pages = Option, + Guides = Option, + Grid = Option, + Snap = Option, + Groups = Option, + Meta = Option, +> { /// Required millimetre artboard dimensions. - pub board: Value, + pub board: Board, /// Required kerf settings. - pub kerf: Value, + pub kerf: Kerf, /// Required drawing layers. - pub layers: Value, + pub layers: Layers, /// Next document object id; absent in early version 1 files, where it defaults to 1. #[serde(rename = "next-shape-id", default = "default_next_shape_id")] pub next_shape_id: u64, /// Embedded raster images; absent in early version 1 files, where it defaults to empty. #[serde(default)] - pub images: Vec, + pub images: Vec, /// Optional canvas page setup, omitted when absent. - #[serde(rename = "board-setup", default, skip_serializing_if = "Option::is_none")] - pub board_setup: Option, + #[serde(rename = "board-setup", default, skip_serializing_if = "is_default")] + pub board_setup: BoardSetup, /// Optional pages, omitted when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pages: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub pages: Pages, /// Optional guides, omitted when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub guides: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub guides: Guides, /// Optional grid settings, omitted when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub grid: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub grid: Grid, /// Optional snap settings, omitted when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub snap: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub snap: Snap, /// Optional groups, omitted when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub groups: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub groups: Groups, /// Optional user metadata, omitted when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub meta: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub meta: Meta, +} + +fn is_default(value: &T) -> bool { + value == &T::default() } fn default_next_shape_id() -> u64 { 1 } -/// A native document and its flattened wire fields. +/// A native document and its flattened, typed wire fields. #[derive(Debug, Clone, PartialEq)] -pub struct File { - /// The fields after the header have been parsed, with app-domain objects still encoded as - /// JSON values. - pub document: DocumentFields, +pub struct File { + /// Typed document fields after the shared codec has checked the header and schema. + pub document: T, } -impl File { +impl File { /// Builds a native document from its wire fields. - pub fn new(document: DocumentFields) -> Self { + pub fn new(document: T) -> Self { Self { document } } - /// Parses JSON, validates the format header and version, then reads the wire fields. + /// Writes the current format header and flattened wire fields as indented JSON. + pub fn to_json_pretty(&self) -> Result + where + T: Serialize, + { + let raw = RawFileRef { header: HeaderRef { name: FORMAT_NAME, version: FORMAT_VERSION }, document: &self.document }; + let value = serde_json::to_value(raw).map_err(Error::Json)?; + serde_json::to_string_pretty(&value).map_err(Error::Json) + } +} + +impl File { + /// Parses JSON, validates the format header and version, then reads the untyped wire fields. pub fn read(text: &str) -> Result { let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?; Self::from_wire(wire) @@ -118,15 +151,40 @@ impl File { if version != FORMAT_VERSION { return Err(Error::Version { found: version, supported: FORMAT_VERSION }); } - let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?; + let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?; Ok(Self { document: raw.document }) } +} - /// Writes the current format header and flattened wire fields as indented JSON. - pub fn to_json_pretty(&self) -> Result { - let raw = RawFileRef { header: HeaderRef { name: FORMAT_NAME, version: FORMAT_VERSION }, document: &self.document }; - let value = serde_json::to_value(raw).map_err(Error::Json)?; - serde_json::to_string_pretty(&value).map_err(Error::Json) +impl File +where + T: for<'de> Deserialize<'de>, +{ + /// Parses JSON into a typed document model after validating the shared format header. + pub fn read_typed(text: &str) -> Result { + let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?; + Self::from_typed_wire(wire) + } + + /// Parses JSON from a reader into a typed document model. + pub fn read_typed_from(reader: impl Read) -> Result { + let wire: RawWire = serde_json::from_reader(reader).map_err(Error::Json)?; + Self::from_typed_wire(wire) + } + + fn from_typed_wire(wire: RawWire) -> Result { + let header = wire.fields.get(HEADER_KEY).and_then(Value::as_object).ok_or(Error::NotStencilCraft)?; + if header.get("name").and_then(Value::as_str) != Some(FORMAT_NAME) { + return Err(Error::NotStencilCraft); + } + let Some(version) = header.get("version").and_then(Value::as_u64) else { + return Err(Error::NotStencilCraft); + }; + if version != FORMAT_VERSION { + return Err(Error::Version { found: version, supported: FORMAT_VERSION }); + } + let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?; + Ok(Self { document: raw.document }) } } @@ -137,11 +195,11 @@ struct Header { } #[derive(Debug, Deserialize)] -struct RawFile { +struct RawFile { #[serde(rename = "stencilcraft")] _header: Header, #[serde(flatten)] - document: DocumentFields, + document: T, } /// A top-level streaming visitor that stores only fields owned by this wire schema. @@ -277,11 +335,11 @@ struct HeaderRef<'a> { } #[derive(Serialize)] -struct RawFileRef<'a> { +struct RawFileRef<'a, T> { #[serde(rename = "stencilcraft")] header: HeaderRef<'a>, #[serde(flatten)] - document: &'a DocumentFields, + document: &'a T, } /// A file was malformed, belongs to another format, or uses an unsupported version. From 166d0d21274aba0b761cd312438b7f075b5b8e5d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 03:46:47 -0500 Subject: [PATCH 028/118] craft-stencilcraft-document: share the native document model --- Cargo.lock | 13 + Cargo.toml | 1 + README.md | 3 +- crates/stencilcraft-document/Cargo.toml | 20 + crates/stencilcraft-document/README.md | 9 + crates/stencilcraft-document/src/canvas.rs | 580 ++++++++++++ crates/stencilcraft-document/src/geom.rs | 305 +++++++ crates/stencilcraft-document/src/lib.rs | 841 ++++++++++++++++++ .../stencilcraft-document/src/production.rs | 206 +++++ crates/stencilcraft-format/README.md | 15 +- 10 files changed, 1986 insertions(+), 7 deletions(-) create mode 100644 crates/stencilcraft-document/Cargo.toml create mode 100644 crates/stencilcraft-document/README.md create mode 100644 crates/stencilcraft-document/src/canvas.rs create mode 100644 crates/stencilcraft-document/src/geom.rs create mode 100644 crates/stencilcraft-document/src/lib.rs create mode 100644 crates/stencilcraft-document/src/production.rs diff --git a/Cargo.lock b/Cargo.lock index fd986012c..e9a21eda5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2198,6 +2198,19 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-stencilcraft-document" +version = "0.1.0" +dependencies = [ + "craft-atomic", + "craft-stencilcraft-format", + "craft-svg", + "kurbo 0.13.1", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-stencilcraft-format" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 170ad8641..12d961be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ craft-settings = { path = "crates/settings" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } craft-stencilcraft-format = { path = "crates/stencilcraft-format" } +craft-stencilcraft-document = { path = "crates/stencilcraft-document" } craft-svg-paths = { path = "crates/svg-paths" } craft-stream-codecs = { path = "crates/stream-codecs" } craft-text = { path = "crates/text" } diff --git a/README.md b/README.md index c79fa2aff..d94b57690 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,8 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit - [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies. - [`craft-sf2`](crates/sf2/README.md): SoundFont 2 (`.sf2`) reader: the sample pool and, per preset, the key and velocity regions a note sounds, with tuning, loop points, pan, level and volume envelope resolved from the preset and instrument zones. Bounded on hostile input. From LoopCraft. - [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused. -- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. The app retains its document model and domain validation. +- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. +- `craft-stencilcraft-document`: StencilCraft's typed document model, geometry helpers, validation, and native `.stencilcraft` codec. - [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft. - [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks SVGZ within a byte limit, decodes UTF-8, UTF-16 and ISO 8859-1, and compresses; `guard` bounds diff --git a/crates/stencilcraft-document/Cargo.toml b/crates/stencilcraft-document/Cargo.toml new file mode 100644 index 000000000..6d5ff6b25 --- /dev/null +++ b/crates/stencilcraft-document/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "craft-stencilcraft-document" +description = "StencilCraft document model, validation, geometry, and native file codec" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-atomic = { workspace = true } +craft-stencilcraft-format = { workspace = true } +craft-svg = { workspace = true } +kurbo = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["rc"] } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/stencilcraft-document/README.md b/crates/stencilcraft-document/README.md new file mode 100644 index 000000000..d286c5789 --- /dev/null +++ b/crates/stencilcraft-document/README.md @@ -0,0 +1,9 @@ +# craft-stencilcraft-document + +Shared StencilCraft document model, validation, geometry helpers, and native file codec. +Applications re-export these model types rather than defining a second set of serializable +document types. The `.stencilcraft` header and typed wire schema are provided by +`craft-stencilcraft-format`. + +The crate's `Document::load_json` and `Document::save_json` APIs read and write the versioned +native format. File path I/O helpers preserve application paths in errors and use atomic writes. diff --git a/crates/stencilcraft-document/src/canvas.rs b/crates/stencilcraft-document/src/canvas.rs new file mode 100644 index 000000000..69878cb5e --- /dev/null +++ b/crates/stencilcraft-document/src/canvas.rs @@ -0,0 +1,580 @@ +//! Canvas model beyond shapes: pages (artboards), page presets, ruler guides, grid and +//! snap settings, document metadata and groups. Everything here is plain data stored in +//! the `.stencilcraft` file; behaviour lives in the engine. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{Board, DocError, Document, Shape}; + +/// Most pages a document may hold (the main board included). +pub const MAX_PAGES: usize = 100; +/// Most ruler guides a document may hold. +pub const MAX_GUIDES: usize = 1000; +/// Most groups a document may hold. +pub const MAX_GROUPS: usize = 100_000; +/// Deepest group nesting accepted. +pub const MAX_GROUP_DEPTH: usize = 64; +/// Longest title, page/layer/group name, colour or mat label, in bytes. +pub const MAX_NAME_BYTES: usize = 1024; +/// Longest notes text, in bytes. +pub const MAX_NOTES_BYTES: usize = 64 * 1024; +/// Largest stored thumbnail, in bytes. +pub const MAX_THUMBNAIL_BYTES: usize = 256 * 1024; + +const INCH_MM: f64 = 25.4; + +/// Labels that describe a page beyond its size: the mat it sits on, the media loaded and +/// the page colour shown on the canvas. All optional; empty means "not set". +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct PageSetup { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mat: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media: Option, + /// Page colour, `#rrggbb`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + /// Cut mirrored (heat-transfer material is cut face down). + #[serde(default, skip_serializing_if = "crate::is_false")] + pub mirror: bool, + /// The page is printed with registration marks for Print & Cut. + #[serde(default, skip_serializing_if = "crate::is_false")] + pub registration_marks: bool, +} + +impl PageSetup { + pub fn is_empty(&self) -> bool { + self == &PageSetup::default() + } +} + +/// An extra page (artboard) placed on the document plane. The main board is page 0 at the +/// origin; extra pages sit beside it, and a shape belongs to the page under its centre. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Page { + pub x_mm: f64, + pub y_mm: f64, + pub width_mm: f64, + pub height_mm: f64, + #[serde(flatten)] + pub setup: PageSetup, +} + +impl Page { + pub fn rect(&self) -> kurbo::Rect { + kurbo::Rect::new(self.x_mm, self.y_mm, self.x_mm + self.width_mm, self.y_mm + self.height_mm) + } +} + +/// Common page and mat sizes. Sizes are portrait (width ≤ height); see [`Orientation`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PagePreset { + #[serde(rename = "letter")] + Letter, + #[serde(rename = "a4")] + A4, + #[serde(rename = "mat-12x12")] + Mat12x12, + #[serde(rename = "mat-12x24")] + Mat12x24, + #[serde(rename = "joy-mat-4.5x6.5")] + JoyMat4_5x6_5, + #[serde(rename = "joy-mat-4.5x12")] + JoyMat4_5x12, + #[serde(rename = "joy-xtra-mat-8.5x12")] + JoyXtraMat8_5x12, + /// Width and height are given explicitly. + #[serde(rename = "custom")] + Custom, +} + +impl PagePreset { + /// Portrait size in millimetres; `None` for `Custom`. + pub fn size_mm(self) -> Option<(f64, f64)> { + let inches = |w: f64, h: f64| Some((w * INCH_MM, h * INCH_MM)); + match self { + PagePreset::Letter => inches(8.5, 11.0), + PagePreset::A4 => Some((210.0, 297.0)), + PagePreset::Mat12x12 => inches(12.0, 12.0), + PagePreset::Mat12x24 => inches(12.0, 24.0), + PagePreset::JoyMat4_5x6_5 => inches(4.5, 6.5), + PagePreset::JoyMat4_5x12 => inches(4.5, 12.0), + PagePreset::JoyXtraMat8_5x12 => inches(8.5, 12.0), + PagePreset::Custom => None, + } + } + + /// The mat label a mat preset implies (`None` for paper sizes and custom). + pub fn mat_label(self) -> Option<&'static str> { + match self { + PagePreset::Mat12x12 => Some("12x12"), + PagePreset::Mat12x24 => Some("12x24"), + PagePreset::JoyMat4_5x6_5 => Some("joy-4.5x6.5"), + PagePreset::JoyMat4_5x12 => Some("joy-4.5x12"), + PagePreset::JoyXtraMat8_5x12 => Some("joy-xtra-8.5x12"), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Orientation { + Portrait, + Landscape, +} + +impl Orientation { + /// `(w, h)` ordered for this orientation. + pub fn apply(self, (w, h): (f64, f64)) -> (f64, f64) { + let (short, long) = if w <= h { (w, h) } else { (h, w) }; + match self { + Orientation::Portrait => (short, long), + Orientation::Landscape => (long, short), + } + } +} + +/// Which way a ruler guide runs. A horizontal guide is the line `y = position`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GuideAxis { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Guide { + pub axis: GuideAxis, + pub position_mm: f64, + #[serde(default, skip_serializing_if = "crate::is_false")] + pub locked: bool, +} + +/// The background grid: major lines every `spacing_mm`, split into `subdivisions`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Grid { + pub spacing_mm: f64, + pub subdivisions: u32, + #[serde(default)] + pub visible: bool, +} + +impl Default for Grid { + fn default() -> Self { + Self { spacing_mm: 10.0, subdivisions: 1, visible: false } + } +} + +impl Grid { + /// Distance between snap lines: spacing over subdivisions. + pub fn step_mm(&self) -> f64 { + self.spacing_mm / f64::from(self.subdivisions.max(1)) + } +} + +/// Which targets the snapping service considers, and how close counts. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SnapSettings { + #[serde(default)] + pub grid: bool, + #[serde(default = "crate::yes")] + pub guides: bool, + /// Edges and centres of other shapes and of pages. + #[serde(default = "crate::yes")] + pub objects: bool, + /// Smart guides: equal spacing between neighbours. + #[serde(default = "crate::yes")] + pub spacing: bool, + /// Snap to point: shapes' anchor points (Illustrator's View › Snap to Point). + #[serde(default)] + pub points: bool, + #[serde(default = "default_tolerance")] + pub tolerance_mm: f64, +} + +fn default_tolerance() -> f64 { + 2.0 +} + +impl Default for SnapSettings { + fn default() -> Self { + Self { grid: false, guides: true, objects: true, spacing: true, points: false, tolerance_mm: default_tolerance() } + } +} + +/// Title, notes and a small preview, stored in the file for file browsers and recovery. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct Metadata { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub title: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub notes: String, + /// A small standalone SVG of the design (see `Document::render_thumbnail`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thumbnail_svg: Option, +} + +impl Metadata { + pub fn is_empty(&self) -> bool { + self == &Metadata::default() + } +} + +/// A member of a group: a shape or another group. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GroupChild { + Shape(u64), + Group(u64), +} + +/// Shapes and groups that move and select together. Grouping never changes how shapes +/// cut, nor their stacking order, which stays with their layers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Group { + pub id: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + pub children: Vec, +} + +/// `#rrggbb` (or `#rgb`) to RGB; `None` for anything else. +pub fn parse_hex_color(text: &str) -> Option<[u8; 3]> { + let hex = text.strip_prefix('#')?; + let digit = |i: usize| u8::from_str_radix(hex.get(i..i + 1)?, 16).ok(); + match hex.len() { + 6 => { + let byte = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok(); + Some([byte(0)?, byte(2)?, byte(4)?]) + } + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + _ => None, + } +} + +/// Canonical lowercase `#rrggbb`. +pub fn format_hex_color([r, g, b]: [u8; 3]) -> String { + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Euclidean RGB distance (0 to about 441.7). +pub fn color_distance(a: [u8; 3], b: [u8; 3]) -> f64 { + a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum::().sqrt() +} + +fn check_name(text: &str, what: &str) -> Result<(), DocError> { + if text.len() > MAX_NAME_BYTES { + return Err(DocError::Model(format!("{what}: longer than {MAX_NAME_BYTES} bytes"))); + } + Ok(()) +} + +fn check_color(color: Option<&str>, what: &str) -> Result<(), DocError> { + match color { + Some(c) if parse_hex_color(c).is_none() => { + Err(DocError::Model(format!("{what}: `{}` is not a #rrggbb colour", c.chars().take(16).collect::()))) + } + _ => Ok(()), + } +} + +fn check_setup(setup: &PageSetup, what: &str) -> Result<(), DocError> { + check_name(&setup.name, what)?; + for label in [&setup.mat, &setup.media].into_iter().flatten() { + check_name(label, what)?; + } + check_color(setup.color.as_deref(), what) +} + +impl Document { + /// Pages, guides, grid, snapping, colours, metadata and groups: finite, within caps, + /// and groups form a forest (unknown shape members are allowed: they are pruned). + pub(crate) fn validate_canvas(&self) -> Result<(), DocError> { + let bad = |message: String| Err(DocError::Model(message)); + if self.pages.len() >= MAX_PAGES { + return bad(format!("pages: more than {MAX_PAGES}")); + } + check_setup(&self.board_setup, "board")?; + for (i, page) in self.pages.iter().enumerate() { + let finite = [page.x_mm, page.y_mm, page.width_mm, page.height_mm].iter().all(|v| v.is_finite() && v.abs() < 1e7); + if !finite || page.width_mm <= 0.0 || page.height_mm <= 0.0 { + return bad(format!("page {}: position and size must be finite, size positive", i + 1)); + } + check_setup(&page.setup, "page")?; + } + if self.guides.len() > MAX_GUIDES { + return bad(format!("guides: more than {MAX_GUIDES}")); + } + if self.guides.iter().any(|g| !g.position_mm.is_finite()) { + return bad("guides: positions must be finite".to_string()); + } + if !(self.grid.spacing_mm.is_finite() && (0.01..=10_000.0).contains(&self.grid.spacing_mm) && (1..=100).contains(&self.grid.subdivisions)) { + return bad("grid: spacing must be 0.01-10000 mm, subdivisions 1-100".to_string()); + } + if !(0.0..=1000.0).contains(&self.snap.tolerance_mm) { + return bad("snap: tolerance must be 0-1000 mm".to_string()); + } + for layer in &self.layers { + check_name(&layer.name, "layer name")?; + check_color(layer.color.as_deref(), "layer colour")?; + } + check_name(&self.meta.title, "title")?; + if self.meta.notes.len() > MAX_NOTES_BYTES { + return bad(format!("notes: longer than {MAX_NOTES_BYTES} bytes")); + } + if self.meta.thumbnail_svg.as_ref().is_some_and(|t| t.len() > MAX_THUMBNAIL_BYTES) { + return bad(format!("thumbnail: larger than {MAX_THUMBNAIL_BYTES} bytes")); + } + self.validate_groups() + } + + fn validate_groups(&self) -> Result<(), DocError> { + let bad = |message: String| Err(DocError::Model(message)); + if self.groups.len() > MAX_GROUPS { + return bad(format!("groups: more than {MAX_GROUPS}")); + } + let ids: BTreeSet = self.groups.iter().map(|g| g.id).collect(); + if ids.len() != self.groups.len() { + return bad("groups: ids must be unique".to_string()); + } + let mut parents: BTreeMap = BTreeMap::new(); + for group in &self.groups { + check_name(&group.name, "group name")?; + for child in &group.children { + if let GroupChild::Group(id) = child + && !ids.contains(id) + { + return bad(format!("group {}: member group {id} does not exist", group.id)); + } + if parents.insert(*child, group.id).is_some() { + return bad(format!("groups: {child:?} belongs to two groups")); + } + } + } + // With one parent each, a cycle is a walk up that never reaches a root. + for id in &ids { + let mut current = GroupChild::Group(*id); + let mut depth = 0; + while let Some(parent) = parents.get(¤t) { + depth += 1; + if depth > MAX_GROUP_DEPTH { + return bad(format!("groups: nesting deeper than {MAX_GROUP_DEPTH}, or a cycle, at group {id}")); + } + current = GroupChild::Group(*parent); + } + } + Ok(()) + } + + /// Whether shape `id` may not be edited: the shape or its layer is locked. + pub fn shape_locked(&self, id: u64) -> bool { + self.layers.iter().any(|layer| layer.shapes.iter().any(|s| s.id == id && (s.locked || layer.locked))) + } + + /// Index of the layer holding shape `id`. + pub fn layer_of_shape(&self, id: u64) -> Option { + self.layers.iter().position(|layer| layer.shapes.iter().any(|s| s.id == id)) + } + + pub fn group(&self, id: u64) -> Option<&Group> { + self.groups.iter().find(|g| g.id == id) + } + + /// The group directly holding `child`. + pub fn group_parent(&self, child: GroupChild) -> Option { + self.groups.iter().find(|g| g.children.contains(&child)).map(|g| g.id) + } + + /// Each group member's direct parent group; build once for many [`group_chain_in`] calls. + pub fn group_parent_map(&self) -> BTreeMap { + self.groups.iter().flat_map(|g| g.children.iter().map(move |c| (*c, g.id))).collect() + } + + /// Groups holding `child`, outermost first. + pub fn group_chain(&self, child: GroupChild) -> Vec { + group_chain_in(&self.group_parent_map(), child) + } + + /// Every existing shape inside group `id`, nested groups included, in document order. + pub fn group_shapes(&self, id: u64) -> Vec { + let mut members = BTreeSet::new(); + let mut seen = BTreeSet::new(); + let mut stack = vec![id]; + while let Some(gid) = stack.pop() { + if !seen.insert(gid) { + continue; + } + for child in self.group(gid).map_or(&[][..], |g| &g.children) { + match child { + GroupChild::Shape(s) => { + members.insert(*s); + } + GroupChild::Group(g) => stack.push(*g), + } + } + } + self.shapes().map(|(_, s)| s.id).filter(|s| members.contains(s)).collect() + } + + /// The groups without members that no longer exist; groups left empty go too. + pub fn pruned_groups(&self) -> Vec { + let shapes: BTreeSet = self.shapes().map(|(_, s)| s.id).collect(); + let mut groups = self.groups.clone(); + loop { + let before: usize = groups.iter().map(|g| g.children.len() + 1).sum(); + let live: BTreeSet = groups.iter().map(|g| g.id).collect(); + for group in &mut groups { + group.children.retain(|child| match child { + GroupChild::Shape(id) => shapes.contains(id), + GroupChild::Group(id) => live.contains(id), + }); + } + groups.retain(|g| !g.children.is_empty()); + let after: usize = groups.iter().map(|g| g.children.len() + 1).sum(); + if after == before { + return groups; + } + } + } + + /// A free group id. + pub fn next_group_id(&self) -> u64 { + self.groups.iter().map(|g| g.id).max().map_or(1, |m| m.saturating_add(1)) + } + + /// All pages: the main board (index 0, at the origin) then the extra pages. + pub fn all_pages(&self) -> Vec { + let mut pages = + vec![Page { x_mm: 0.0, y_mm: 0.0, width_mm: self.board.width_mm, height_mm: self.board.height_mm, setup: self.board_setup.clone() }]; + pages.extend(self.pages.iter().cloned()); + pages + } + + /// The page a shape belongs to: the first extra page under its bounds centre, else 0. + pub fn page_of_shape(&self, shape: &Shape) -> usize { + let Some(center) = shape.bounds().map(|b| b.center()) else { + return 0; + }; + self.pages.iter().position(|p| p.rect().contains(center)).map_or(0, |i| i + 1) + } + + /// One page as its own document, ready to export or cut: the board is the page, and + /// the shapes on that page are moved to its origin. `None` for an unknown page. + pub fn page_document(&self, index: usize) -> Option { + let page = self.all_pages().get(index)?.clone(); + let mut out = self.clone(); + out.board = Board { width_mm: page.width_mm, height_mm: page.height_mm }; + out.board_setup = page.setup.clone(); + out.pages.clear(); + out.guides.clear(); + let shift = kurbo::Affine::translate((-page.x_mm, -page.y_mm)); + for layer in &mut out.layers { + layer.shapes.retain(|s| self.page_of_shape(s) == index); + for shape in &mut layer.shapes { + shape.path.apply_affine(shift); + } + } + out.groups = out.pruned_groups(); + Some(out) + } + + /// A small standalone SVG preview of the board's output shapes, at most `max_bytes` + /// long: shapes past the budget are left out. Coordinates keep two decimals. + pub fn render_thumbnail(&self, max_bytes: usize) -> String { + let (w, h) = (self.board.width_mm, self.board.height_mm); + // Documents are y-up; flip into SVG's y-down space. The shared SVG writer owns markup, + // escaping, numeric formatting, and path-data formatting. + let mut svg = thumbnail_writer(w, h); + let mut empty = svg.clone(); + empty.line(1); + empty.close(); + empty.line(0); + empty.close(); + let empty_len = empty.finish().len(); + if empty_len > max_bytes { + return String::new(); + } + let mut remaining = max_bytes - empty_len; + 'layers: for layer in self.layers.iter().filter(|l| l.is_output()) { + for shape in layer.shapes.iter().filter(|s| !s.hidden) { + let mut element = craft_svg::write::SvgWriter::fragment(); + element.line(2); + element.tag("path").attr("d", craft_svg::write::path_data_fixed(&shape.path, 2)).empty(); + let element = element.finish(); + if element.len() > remaining { + break 'layers; + } + remaining -= element.len(); + svg.raw(&element); + } + } + svg.line(1); + svg.close(); + svg.line(0); + svg.close(); + svg.finish() + } +} + +fn thumbnail_writer(width_mm: f64, height_mm: f64) -> craft_svg::write::SvgWriter { + use craft_svg::write::{SvgWriter, num}; + + let mut writer = SvgWriter::fragment(); + writer.root().attr("viewBox", format!("0 0 {} {}", num(width_mm, 2), num(height_mm, 2))).open(); + writer + .tag("g") + .attr("transform", format!("matrix(1 0 0 -1 0 {})", num(height_mm, 2))) + .attr("fill", "none") + .attr("stroke", "#000") + .attr("stroke-width", num((width_mm.max(height_mm) / 200.0).max(0.01), 2)) + .open(); + writer +} + +/// Groups holding `child` according to `parents` ([`Document::group_parent_map`]), +/// outermost first. Stops at a cycle or past the depth cap. +pub fn group_chain_in(parents: &BTreeMap, child: GroupChild) -> Vec { + let mut chain = Vec::new(); + let mut current = child; + while let Some(parent) = parents.get(¤t) { + if chain.contains(parent) || chain.len() > MAX_GROUP_DEPTH { + break; + } + chain.push(*parent); + current = GroupChild::Group(*parent); + } + chain.reverse(); + chain +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presets_and_orientation() { + let (w, h) = PagePreset::Letter.size_mm().unwrap(); + assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9); + assert_eq!(Orientation::Landscape.apply((210.0, 297.0)), (297.0, 210.0)); + assert_eq!(Orientation::Portrait.apply((297.0, 210.0)), (210.0, 297.0)); + assert!(PagePreset::Custom.size_mm().is_none()); + let json = serde_json::to_string(&PagePreset::JoyMat4_5x6_5).unwrap(); + assert_eq!(json, "\"joy-mat-4.5x6.5\""); + } + + #[test] + fn hex_colors() { + assert_eq!(parse_hex_color("#FF0080"), Some([255, 0, 128])); + assert_eq!(parse_hex_color("#f08"), Some([255, 0, 136])); + assert_eq!(parse_hex_color("ff0080"), None); + assert_eq!(parse_hex_color("#ff00"), None); + assert_eq!(parse_hex_color("#é0000"), None); + assert_eq!(format_hex_color([1, 2, 255]), "#0102ff"); + assert!(color_distance([0, 0, 0], [3, 4, 0]) - 5.0 < 1e-9); + } +} diff --git a/crates/stencilcraft-document/src/geom.rs b/crates/stencilcraft-document/src/geom.rs new file mode 100644 index 000000000..e569afeec --- /dev/null +++ b/crates/stencilcraft-document/src/geom.rs @@ -0,0 +1,305 @@ +//! Geometry primitives for StencilCraft: kurbo re-exports, millimetre helpers, +//! path flattening. +//! +//! The bottom layer of the workspace (L1 in `docs/architecture.md`): nothing here +//! depends on the document model, kerf, exports or the engine. All coordinates are +//! millimetres; coordinate-axis orientation is defined at each format boundary. + +pub use kurbo::{Affine, BezPath, Circle, CubicBez, Ellipse, Line, ParamCurve, Point, QuadBez, Rect, Shape, Vec2}; + +#[derive(Debug, thiserror::Error)] +#[error("geometry: {0}")] +pub struct GeomError(String); + +pub type Result = std::result::Result; + +/// The smallest length that can meaningfully exist on an artboard; below this a +/// value is treated as zero. Values above this but non-finite are always an error. +pub const MM_EPSILON: f64 = 1e-9; + +/// Millimetres per inch: the one inch/mm conversion factor (inch-based mats and page sizes). +pub const MM_PER_INCH: f64 = 25.4; + +/// Converts inches at an input boundary to document millimetres. +pub const fn inches_to_mm(inches: f64) -> f64 { + inches * MM_PER_INCH +} + +/// Converts document millimetres to inches at a display boundary. +pub const fn mm_to_inches(mm: f64) -> f64 { + mm / MM_PER_INCH +} + +/// Map an artboard's downward-growing Y coordinate to a Cartesian Y coordinate. +/// +/// The board's top is `height_mm` and its bottom is zero in the resulting space. +/// Values outside the board remain outside; non-finite inputs or results are rejected. +pub fn artboard_y_to_cartesian(y_mm: f64, height_mm: f64) -> Result { + check_finite_mm(y_mm, "artboard y")?; + check_finite_mm(height_mm, "artboard height")?; + check_finite_mm(height_mm - y_mm, "cartesian y") +} + +/// Rejects a non-finite millimetre length before it can poison a document. +pub fn check_finite_mm(value: f64, what: &'static str) -> Result { + if !value.is_finite() { + return Err(GeomError(format!("{what}: not a finite length"))); + } + let _ = MM_EPSILON; + Ok(value) +} + +/// A flattened subpath: the raw points of a contour, plus whether the source path +/// closed it. Used by kerf (offsetting) and every polyline-based exporter. +#[derive(Debug, Clone, PartialEq)] +pub struct Polyline { + pub points: Vec, + pub closed: bool, +} + +impl Polyline { + pub fn from_path(path: &BezPath) -> Vec { + path_to_polylines(path, DEFAULT_FLATTEN_STEPS) + } +} + +/// Flattening resolution: samples per Bézier segment. 32 steps at artboard scale +/// keeps chord error far below a typical kerf width. +pub const DEFAULT_FLATTEN_STEPS: usize = 32; + +/// Convert a path into polylines by sampling Bézier segments (`steps` samples per +/// segment). Closed subpaths produce `closed: true` polylines that do not repeat +/// the start point. +pub fn path_to_polylines(path: &BezPath, steps: usize) -> Vec { + let steps = steps.max(2); + let mut out: Vec = Vec::new(); + let mut current: Option = None; + let mut start = Point::ZERO; + let mut cursor = Point::ZERO; + + for elem in path.elements() { + match *elem { + kurbo::PathEl::MoveTo(p) => { + if let Some(done) = current.take() { + out.push(done); + } + start = p; + cursor = p; + current = Some(Polyline { points: vec![p], closed: false }); + } + kurbo::PathEl::LineTo(p) => { + if let Some(pl) = current.as_mut() { + pl.points.push(p); + } + cursor = p; + } + kurbo::PathEl::ClosePath => { + if let Some(pl) = current.as_mut() { + pl.closed = true; + } + cursor = start; + } + kurbo::PathEl::QuadTo(c, p) => { + if let Some(pl) = current.as_mut() { + let quad = QuadBez::new(cursor, c, p); + for i in 1..steps { + let t = i as f64 / steps as f64; + pl.points.push(quad.eval(t)); + } + } + cursor = p; + } + kurbo::PathEl::CurveTo(c1, c2, p) => { + if let Some(pl) = current.as_mut() { + let cubic = CubicBez::new(cursor, c1, c2, p); + for i in 1..steps { + let t = i as f64 / steps as f64; + pl.points.push(cubic.eval(t)); + } + } + cursor = p; + } + } + } + if let Some(done) = current.take() { + out.push(done); + } + out +} + +/// Signed area of a closed polyline (shoelace): positive when wound +/// counter-clockwise with y up. An open polyline contributes 0. +pub fn signed_area(points: &[Point]) -> f64 { + if points.len() < 3 { + return 0.0; + } + let mut sum = 0.0; + for i in 0..points.len() { + let a = points[i]; + let b = points[(i + 1) % points.len()]; + sum += a.x * b.y - b.x * a.y; + } + sum / 2.0 +} + +/// Bounds of a set of points, or `None` when empty. +pub fn bounds_of_points(points: &[Point]) -> Option { + let mut it = points.iter(); + let first = *it.next()?; + let mut acc = Rect::from_points(first, first); + for p in it { + acc = acc.union(Rect::from_points(*p, *p)); + } + Some(acc) +} + +/// Axis-aligned bounds of every point of every polyline. +pub fn bounds_of_polylines(lines: &[Polyline]) -> Option { + let mut acc: Option = None; + for pl in lines { + if let Some(r) = bounds_of_points(&pl.points) { + acc = Some(match acc { + Some(prev) => prev.union(r), + None => r, + }); + } + } + acc +} + +/// Exact (non-flattened) control-point bounds of a path through kurbo. +pub fn bounds_of_shape(path: &BezPath) -> Option { + path.bounding_box().as_rect() +} + +/// Display units. The document is always millimetres; convert only where a value meets +/// the user (fields, rulers, callouts), through these helpers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Unit { + #[default] + Mm, + Cm, + In, + Pt, +} + +impl Unit { + pub const ALL: [Unit; 4] = [Unit::Mm, Unit::Cm, Unit::In, Unit::Pt]; + + /// Millimetres in one of this unit. + pub fn mm_per_unit(self) -> f64 { + match self { + Unit::Mm => 1.0, + Unit::Cm => 10.0, + Unit::In => 25.4, + Unit::Pt => 25.4 / 72.0, + } + } + + pub fn to_mm(self, value: f64) -> f64 { + value * self.mm_per_unit() + } + + pub fn from_mm(self, mm: f64) -> f64 { + mm / self.mm_per_unit() + } + + pub fn suffix(self) -> &'static str { + match self { + Unit::Mm => "mm", + Unit::Cm => "cm", + Unit::In => "in", + Unit::Pt => "pt", + } + } + + /// `mm` shown in this unit with `decimals` places and the suffix, e.g. `1.000 in`. + pub fn format(self, mm: f64, decimals: usize) -> String { + format!("{:.*} {}", decimals.min(9), self.from_mm(mm), self.suffix()) + } + + /// Parses `12.5`, `12.5mm`, `1 in`, `3cm`, `72 pt` or `2"` into millimetres; a bare + /// number is in this unit. `None` for anything else or a non-finite result. + pub fn parse_to_mm(self, text: &str) -> Option { + let text = text.trim(); + let (number, unit) = Unit::ALL + .iter() + .find_map(|u| text.strip_suffix(u.suffix()).map(|n| (n, *u))) + .or_else(|| text.strip_suffix('"').map(|n| (n, Unit::In))) + .unwrap_or((text, self)); + let value: f64 = number.trim().parse().ok()?; + let mm = unit.to_mm(value); + mm.is_finite().then_some(mm) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units_convert_format_and_parse() { + assert_eq!(Unit::In.to_mm(1.0), 25.4); + assert!((Unit::Pt.from_mm(25.4) - 72.0).abs() < 1e-12); + assert_eq!(Unit::Cm.from_mm(15.0), 1.5); + assert_eq!(Unit::In.format(25.4, 3), "1.000 in"); + assert_eq!(Unit::Mm.parse_to_mm("12.5"), Some(12.5)); + assert_eq!(Unit::In.parse_to_mm("2"), Some(50.8)); + assert_eq!(Unit::Mm.parse_to_mm(" 1 in "), Some(25.4)); + assert_eq!(Unit::Mm.parse_to_mm("2\""), Some(50.8)); + assert_eq!(Unit::Mm.parse_to_mm("3cm"), Some(30.0)); + assert_eq!(Unit::Mm.parse_to_mm("abc"), None); + assert_eq!(Unit::Mm.parse_to_mm("1e400"), None); + for unit in Unit::ALL { + assert!((unit.to_mm(unit.from_mm(123.4)) - 123.4).abs() < 1e-9); + } + } + + fn square(side: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to(Point::ZERO); + p.line_to(Point::new(side, 0.0)); + p.line_to(Point::new(side, side)); + p.line_to(Point::new(0.0, side)); + p.close_path(); + p + } + + #[test] + fn flatten_square_closes() { + let lines = path_to_polylines(&square(10.0), 4); + assert_eq!(lines.len(), 1); + let pl = &lines[0]; + assert!(pl.closed); + assert_eq!(pl.points.len(), 4); + assert_eq!(pl.points[0], Point::ZERO); + assert_eq!(pl.points[2], Point::new(10.0, 10.0)); + } + + #[test] + fn area_sign_of_square() { + let lines = path_to_polylines(&square(10.0), 4); + let area = signed_area(&lines[0].points); + assert!((area - 100.0).abs() < 1e-9, "got {area}"); + } + + #[test] + fn cubic_flattens_to_steps_per_segment() { + let mut p = BezPath::new(); + p.move_to(Point::ZERO); + p.curve_to(Point::new(10.0, 0.0), Point::new(20.0, 10.0), Point::new(0.0, 20.0)); + p.close_path(); + let lines = path_to_polylines(&p, 8); + assert_eq!(lines.len(), 1); + // The move plus 7 interior samples; the segment's endpoint is the move origin + // after close, so it is not repeated. + assert_eq!(lines[0].points.len(), 8); + } + + #[test] + fn bounds_union_works() { + let lines = path_to_polylines(&square(10.0), 4); + let b = bounds_of_polylines(&lines).unwrap(); + assert_eq!(b, Rect::new(0.0, 0.0, 10.0, 10.0)); + } +} diff --git a/crates/stencilcraft-document/src/lib.rs b/crates/stencilcraft-document/src/lib.rs new file mode 100644 index 000000000..a966536c5 --- /dev/null +++ b/crates/stencilcraft-document/src/lib.rs @@ -0,0 +1,841 @@ +//! StencilCraft document model and domain validation. +//! +//! A document holds an artboard in millimetres, kerf settings and layers of shapes. +//! Every read path returns `Result` with a calm, printable error: a malformed file +//! must never crash (`AGENTS.md` › "Never crash"), because it is untrusted input. + +pub mod canvas; +pub mod geom; + +use crate::geom::{GeomError, bounds_of_shape, check_finite_mm}; +pub use canvas::{Grid, Group, GroupChild, Guide, GuideAxis, Metadata, Orientation, Page, PagePreset, PageSetup, SnapSettings}; +use kurbo::BezPath; +use serde::{Deserialize, Serialize}; + +pub mod production; +pub use production::{CombineOp, GroupKind, Operation, SendAssign, ShapeProduction, SourceGroup}; + +/// Native format identity and supported version, defined by the shared wire-format crate. +pub use craft_stencilcraft_format::{FORMAT_NAME, FORMAT_VERSION}; + +#[derive(Debug, thiserror::Error)] +pub enum DocError { + #[error("{path}: {source}")] + Io { path: String, source: std::io::Error }, + + #[error("{path}: not a stencilcraft file")] + NotAStencilcraftFile { path: String }, + + #[error("{path}: format version {found}, this build reads {expected}")] + FormatVersion { path: String, found: u64, expected: u64 }, + + #[error("{path}: {source}")] + Parse { path: String, source: serde_json::Error }, + + #[error("{path}: {message}")] + Structure { path: String, message: String }, + + #[error("document: {0}")] + Model(String), + + #[error(transparent)] + Geom(#[from] GeomError), +} + +/// How a shape is physically produced on a machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CutStyle { + /// Trace the closed outline with the cutting tool. + Cut, + /// Trace without cutting: scoring, engraving outlines, marking. + Engrave, +} + +/// Kerf settings held in the document; applying them is `stencilcraft-kerf`'s job. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KerfSettings { + /// Nominal kerf (material removed by the tool), in millimetres. Compensation + /// offsets contours by `width_mm / 2`. + pub width_mm: f64, + /// Whether exports should carry compensated contours already. + pub apply_to_exports: bool, +} + +impl Default for KerfSettings { + fn default() -> Self { + Self { width_mm: 0.0, apply_to_exports: false } + } +} + +/// The cutting sheet: artboard size in millimetres. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Board { + pub width_mm: f64, + pub height_mm: f64, +} + +impl Default for Board { + fn default() -> Self { + // A default sheet for vinyl work: 100 × 150 mm. + Self { width_mm: 100.0, height_mm: 150.0 } + } +} + +/// One layer of the document: a group of shapes with a production style and a +/// visibility flag. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Layer { + pub name: String, + pub kind: CutStyle, + pub visible: bool, + pub shapes: Vec, + /// A locked layer's shapes cannot be selected or edited. + #[serde(default, skip_serializing_if = "is_false")] + pub locked: bool, + /// A template (guide) layer is shown on the canvas but never exported or cut. + #[serde(default, skip_serializing_if = "is_false")] + pub template: bool, + /// Material colour label, `#rrggbb`; Color Sync merges similar ones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +impl Layer { + pub fn new(name: impl Into, kind: CutStyle) -> Self { + Self { name: name.into(), kind, visible: true, shapes: Vec::new(), locked: false, template: false, color: None } + } + + /// Whether exports and cuts include this layer: visible and not a template. + pub fn is_output(&self) -> bool { + self.visible && !self.template + } + + /// Whether newly created shapes may land here: not locked, not a template. + pub fn takes_new_shapes(&self) -> bool { + !self.locked && !self.template + } + + /// Sum of per-shape bounds; `None` when the layer has no shapes or only empty + /// paths. + pub fn bounds(&self) -> Option { + let mut acc: Option = None; + for shape in &self.shapes { + if let Some(r) = shape.bounds() { + acc = Some(match acc { + Some(prev) => prev.union(r), + None => r, + }); + } + } + acc + } +} + +/// A path plus its production style. kurbo's `BezPath` serialises with the +/// workspace's `serde` feature on — no wrapper needed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Shape { + pub id: u64, + pub path: BezPath, + pub style: CutStyle, + /// Hidden shapes are not drawn, selected or exported. + #[serde(default, skip_serializing_if = "is_false")] + pub hidden: bool, + /// Locked shapes cannot be selected or edited until unlocked. + #[serde(default, skip_serializing_if = "is_false")] + pub locked: bool, + /// Live text: the parameters `path` was laid out from, so the text stays editable + /// (`text.edit` lays it out again). Absent for plain paths and in older files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option>, + /// A tool-specific operation for this path (score, emboss, engrave, foil, punch); + /// `None` is a plain cut or engrave pass by `style`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, + /// Production metadata (operation, colours, attach, contours, combine sources). + #[serde(default, skip_serializing_if = "ShapeProduction::is_default")] + pub production: ShapeProduction, +} + +/// Score and emboss tool tip. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Tip { + #[default] + Fine, + Wide, +} + +fn one_pass() -> u32 { + 1 +} + +/// A tool-specific operation a machine backend runs instead of a plain cut/engrave pass. +/// Paths tagged with one live on an engrave layer (they must never be cut through), except +/// `punch`, whose path is the hole outline for display and fallback cutting. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "kebab-case")] +pub enum ToolOp { + /// Crease along the path (scoring stylus/wheel). + Score { + #[serde(default)] + tip: Tip, + #[serde(default = "one_pass")] + passes: u32, + }, + /// Press the path into the material (emboss, or deboss from the other side). + Emboss { + #[serde(default)] + tip: Tip, + #[serde(default = "one_pass")] + passes: u32, + #[serde(default)] + deboss: bool, + }, + /// Rotary/power engraving tool following the path. + PowerEngrave, + /// Heat pen pressing foil along the path. + HeatPen, + /// One punch per contour, at the contour's bounds centre: a backend with a punch tool + /// plunges there instead of cutting the circle. + Punch { diameter_mm: f64 }, +} + +fn is_false(value: &bool) -> bool { + !*value +} + +/// A live text object's parameters. The shape's `path` always holds the laid-out outlines, +/// so readers that ignore `text` (older builds, every export) still cut the right thing. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TextSpec { + pub text: String, + pub font: FontSpec, + /// Em size in points (1 pt = 25.4/72 mm). + pub size: f64, + /// Anchor in mm: the first line's baseline at the alignment point. + pub x: f64, + pub y: f64, + #[serde(default)] + pub alignment: TextAlign, + /// Space added between letters, in percent of the em (0 = the font's own spacing). + #[serde(default)] + pub letter_spacing: f64, + /// Distance between lines in percent of the normal line distance (100 = normal). + #[serde(default = "hundred")] + pub line_spacing: f64, + /// Pair kerning from the font's tables. + #[serde(default = "yes")] + pub kerning: bool, + /// Top-to-bottom columns instead of left-to-right lines. + #[serde(default, skip_serializing_if = "is_false")] + pub vertical: bool, + /// Merge overlapping letter outlines into one cut path (script fonts). + #[serde(default, skip_serializing_if = "is_false")] + pub weld: bool, + #[serde(default, skip_serializing_if = "TextFlow::is_straight")] + pub flow: TextFlow, + /// Per-letter nudges, by character index into `text` (newlines count). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub letters: Vec, + /// Affine `[a, b, c, d, e, f]` applied after layout: the moves, turns and scales made to + /// the shape since its text was laid out. + #[serde(default = "identity", skip_serializing_if = "is_identity")] + pub transform: [f64; 6], +} + +/// A font by family and style, optionally from a font file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FontSpec { + pub family: String, + #[serde(default = "regular")] + pub style: String, + /// The font file the family came from, loaded again when the text is laid out again. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TextAlign { + #[default] + Left, + #[serde(alias = "center", alias = "centre")] + Centre, + Right, + /// Every line but the last is stretched to the widest line. + Justify, +} + +/// How the text runs: straight lines, one line bent round a circle, or one line along a path. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum TextFlow { + #[default] + Straight, + /// One line bent into an arc of this diameter in mm: positive arches up, negative dips. + Curve { diameter_mm: f64 }, + /// One line along `path` (mm). `start` is where the anchor sits, as a fraction of the + /// path's length; `offset_mm` lifts the letters off the path; `flip` runs the text the + /// other way round, on the other side. `shape` names the shape the path was copied from. + OnPath { + #[serde(default)] + path: BezPath, + #[serde(default, skip_serializing_if = "Option::is_none")] + shape: Option, + #[serde(default)] + start: f64, + #[serde(default)] + offset_mm: f64, + #[serde(default)] + flip: bool, + }, +} + +impl TextFlow { + pub fn is_straight(&self) -> bool { + matches!(self, TextFlow::Straight) + } +} + +/// A nudge to one letter: moved by `dx_mm`, `dy_mm` and turned `rotate_deg` about its origin. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct LetterOffset { + pub index: usize, + #[serde(default)] + pub dx_mm: f64, + #[serde(default)] + pub dy_mm: f64, + #[serde(default)] + pub rotate_deg: f64, +} + +fn hundred() -> f64 { + 100.0 +} + +fn yes() -> bool { + true +} + +fn regular() -> String { + "Regular".to_string() +} + +/// The identity affine in `TextSpec::transform` form. +pub const IDENTITY_TRANSFORM: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +fn identity() -> [f64; 6] { + IDENTITY_TRANSFORM +} + +fn is_identity(xf: &[f64; 6]) -> bool { + *xf == IDENTITY_TRANSFORM +} + +impl Shape { + pub fn bounds(&self) -> Option { + bounds_of_shape(&self.path) + } +} + +/// Most placed raster images one document may hold. +pub const MAX_IMAGES: usize = 256; +/// Most base64 PNG bytes one placed image may carry (about 96 MiB of PNG). +pub const MAX_IMAGE_DATA: usize = 128 * 1024 * 1024; + +/// A placed raster image (a trace source, never cut). Pixels are PNG, base64-encoded so +/// the document stays JSON; `Arc` keeps undo snapshots from copying the bytes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RasterImage { + /// Shares the shape id counter, so an id names one object in the document. + pub id: u64, + pub name: String, + pub x_mm: f64, + pub y_mm: f64, + pub width_mm: f64, + pub height_mm: f64, + pub width_px: u32, + pub height_px: u32, + pub png_base64: std::sync::Arc, +} + +impl RasterImage { + fn validate(&self) -> Result<(), DocError> { + for (value, what) in [(self.x_mm, "image x"), (self.y_mm, "image y"), (self.width_mm, "image width"), (self.height_mm, "image height")] { + check_finite_mm(value, what)?; + } + if self.width_mm <= 0.0 || self.height_mm <= 0.0 || self.width_px == 0 || self.height_px == 0 { + return Err(DocError::Model(format!("image {}: width and height must be positive", self.id))); + } + if self.png_base64.len() > MAX_IMAGE_DATA { + return Err(DocError::Model(format!("image {}: pixel data exceeds the 128 MiB limit", self.id))); + } + Ok(()) + } +} + +/// A StencilCraft document: artboard, kerf configuration, layers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Document { + pub board: Board, + pub kerf: KerfSettings, + pub layers: Vec, + /// Next free shape id; maintained by whoever mutates the document. + #[serde(default)] + pub next_shape_id: u64, + /// Placed raster images, bottom first, drawn under the cut layers. + #[serde(default)] + pub images: Vec, + /// Mat, media, colour and name of the main board (page 0). + #[serde(default)] + pub board_setup: PageSetup, + /// Extra pages beside the main board. + #[serde(default)] + pub pages: Vec, + #[serde(default)] + pub guides: Vec, + #[serde(default)] + pub grid: Grid, + #[serde(default)] + pub snap: SnapSettings, + /// Groups; members that no longer exist are ignored and dropped on save. + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub meta: Metadata, +} + +/// Typed fields in the native wire format. Field names and version-1 defaults are owned by +/// `craft-stencilcraft-format`; this alias supplies only StencilCraft's domain types. +type NativeFileFields = craft_stencilcraft_format::DocumentFields< + Board, + KerfSettings, + Vec, + RasterImage, + PageSetup, + Vec, + Vec, + Grid, + SnapSettings, + Vec, + Metadata, +>; + +impl Default for Document { + fn default() -> Self { + Self { + board: Board::default(), + kerf: KerfSettings::default(), + layers: vec![Layer::new("cut", CutStyle::Cut)], + next_shape_id: 1, + images: Vec::new(), + board_setup: PageSetup::default(), + pages: Vec::new(), + guides: Vec::new(), + grid: Grid::default(), + snap: SnapSettings::default(), + groups: Vec::new(), + meta: Metadata::default(), + } + } +} + +impl Document { + /// Shapes across all layers, in order, paired with their layer index. + pub fn shapes(&self) -> impl Iterator { + self.layers.iter().enumerate().flat_map(|(li, l)| l.shapes.iter().map(move |s| (li, s))) + } + + pub fn shape_count(&self) -> usize { + self.layers.iter().map(|l| l.shapes.len()).sum() + } + + pub fn get_shape(&self, id: u64) -> Option<&Shape> { + self.shapes().map(|(_, s)| s).find(|s| s.id == id) + } + + pub fn get_shape_mut(&mut self, id: u64) -> Option<&mut Shape> { + self.layers.iter_mut().flat_map(|l| l.shapes.iter_mut()).find(|s| s.id == id) + } + + /// Allocates the next shape id and records the shape in `layer_index`. + /// Errors come out printable: messages name the layer and the fault. + pub fn push_shape(&mut self, layer_index: usize, path: BezPath, style: CutStyle) -> Result { + if layer_index >= self.layers.len() { + return Err(DocError::Model(format!("layer {layer_index}: no such layer (document has {})", self.layers.len()))); + } + let Some(layer) = self.layers.get_mut(layer_index) else { + return Err(DocError::Model(format!("layer {layer_index}: disappeared"))); + }; + let id = self.next_shape_id; + self.next_shape_id = self.next_shape_id.checked_add(1).ok_or_else(|| DocError::Model("shape ids exhausted".to_string()))?; + let shape = Shape { id, path, style, hidden: false, locked: false, tool: None, text: None, production: ShapeProduction::default() }; + layer.shapes.push(shape.clone()); + Ok(shape) + } + + /// Places a raster image with the next object id and returns that id. + pub fn push_image(&mut self, mut image: RasterImage) -> Result { + if self.images.len() >= MAX_IMAGES { + return Err(DocError::Model(format!("a document holds at most {MAX_IMAGES} images"))); + } + image.id = self.next_shape_id; + image.validate()?; + self.next_shape_id = self.next_shape_id.checked_add(1).ok_or_else(|| DocError::Model("shape ids exhausted".to_string()))?; + let id = image.id; + self.images.push(image); + Ok(id) + } + + pub fn get_image(&self, id: u64) -> Option<&RasterImage> { + self.images.iter().find(|image| image.id == id) + } + + /// Checks the board is a sane sheet: positive, finite dimensions. + pub fn validate(&self) -> Result<(), DocError> { + check_finite_mm(self.board.width_mm, "board width")?; + check_finite_mm(self.board.height_mm, "board height")?; + check_finite_mm(self.kerf.width_mm, "kerf width")?; + if self.board.width_mm <= 0.0 || self.board.height_mm <= 0.0 { + return Err(DocError::Model("board: width and height must be positive".to_string())); + } + if self.kerf.width_mm < 0.0 { + return Err(DocError::Model("kerf: width cannot be negative".to_string())); + } + if self.images.len() > MAX_IMAGES { + return Err(DocError::Model(format!("a document holds at most {MAX_IMAGES} images"))); + } + self.images.iter().try_for_each(RasterImage::validate)?; + self.validate_canvas() + } + + /// Serialises with the format header. The header wraps the document so a reader + /// knows what it is before reading the rest. Canvas fields are written only when + /// set, so a plain document saves exactly as it did before they existed. + pub fn save_json(&self) -> Result { + self.validate()?; + let fields = NativeFileFields { + board: self.board.clone(), + kerf: self.kerf.clone(), + layers: self.layers.clone(), + next_shape_id: self.next_shape_id, + images: self.images.clone(), + board_setup: self.board_setup.clone(), + pages: self.pages.clone(), + guides: self.guides.clone(), + grid: self.grid.clone(), + snap: self.snap.clone(), + groups: self.pruned_groups(), + meta: self.meta.clone(), + }; + craft_stencilcraft_format::File::::new(fields) + .to_json_pretty() + .map_err(|e| DocError::Structure { path: "output".to_string(), message: e.to_string() }) + } + + /// Parses a document from JSON with calm, positioned error messages. + pub fn load_json(json: &str) -> Result { + load_json_named(json, "input") + } +} + +fn load_json_named(json: &str, path: &str) -> Result { + let file = craft_stencilcraft_format::File::::read_typed(json).map_err(|e| match e { + craft_stencilcraft_format::Error::Json(source) => DocError::Parse { path: path.to_string(), source }, + craft_stencilcraft_format::Error::NotStencilCraft => DocError::NotAStencilcraftFile { path: path.to_string() }, + craft_stencilcraft_format::Error::Version { found, supported } => { + DocError::FormatVersion { path: path.to_string(), found, expected: supported } + } + craft_stencilcraft_format::Error::Document { source } => structure_err(path, &source), + _ => DocError::NotAStencilcraftFile { path: path.to_string() }, + })?; + let fields = file.document; + let mut doc = Document { + board: fields.board, + kerf: fields.kerf, + layers: fields.layers, + next_shape_id: fields.next_shape_id, + images: fields.images, + board_setup: fields.board_setup, + pages: fields.pages, + guides: fields.guides, + grid: fields.grid, + snap: fields.snap, + groups: fields.groups, + meta: fields.meta, + }; + doc.validate()?; + doc.groups = doc.pruned_groups(); + Ok(doc) +} + +fn structure_err(path: &str, e: &serde_json::Error) -> DocError { + DocError::Structure { path: path.to_string(), message: e.to_string() } +} + +/// File helpers: errors keep the path so the message names the file. +pub fn save_to(doc: &Document, path: &str) -> Result<(), DocError> { + let json = doc.save_json()?; + craft_atomic::atomic_write(std::path::Path::new(path), json.as_bytes()).map_err(|source| DocError::Io { path: path.to_string(), source }) +} + +pub fn load_from(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(|source| DocError::Io { path: path.to_string(), source })?; + load_json_named(&json, path) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geom::Point; + + fn rect_path(x: f64, y: f64, w: f64, h: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to(Point::new(x, y)); + p.line_to(Point::new(x + w, y)); + p.line_to(Point::new(x + w, y + h)); + p.line_to(Point::new(x, y + h)); + p.close_path(); + p + } + + fn sample() -> Document { + let mut doc = Document::default(); + doc.kerf.width_mm = 0.2; + doc.push_shape(0, rect_path(10.0, 20.0, 30.0, 40.0), CutStyle::Cut).unwrap(); + doc + } + + #[test] + fn round_trip() { + let doc = sample(); + let json = doc.save_json().unwrap(); + let back = Document::load_json(&json).unwrap(); + assert_eq!(doc, back); + } + + #[test] + fn flags_round_trip_and_default_off() { + let mut doc = sample(); + let plain = doc.save_json().unwrap(); + assert!(!plain.contains("hidden") && !plain.contains("locked")); + doc.layers[0].shapes[0].hidden = true; + doc.layers[0].shapes[0].locked = true; + let back = Document::load_json(&doc.save_json().unwrap()).unwrap(); + assert_eq!(doc, back); + } + + #[test] + fn text_round_trips_and_minimal_text_loads() { + let mut doc = sample(); + let plain = doc.save_json().unwrap(); + assert!(!plain.contains("\"text\"")); + doc.layers[0].shapes[0].text = Some(Box::new(TextSpec { + text: "Hi".into(), + font: FontSpec { family: "Sans".into(), style: "Bold".into(), file: None }, + size: 72.0, + x: 1.0, + y: 2.0, + alignment: TextAlign::Centre, + letter_spacing: -5.0, + line_spacing: 120.0, + kerning: false, + vertical: false, + weld: true, + flow: TextFlow::Curve { diameter_mm: 50.0 }, + letters: vec![LetterOffset { index: 1, dx_mm: 1.0, ..LetterOffset::default() }], + transform: IDENTITY_TRANSFORM, + })); + let back = Document::load_json(&doc.save_json().unwrap()).unwrap(); + assert_eq!(doc, back); + // A minimal hand-written text object fills in the defaults. + let json = plain.replace( + "\"style\": \"cut\"", + "\"style\": \"cut\", \"text\": {\"text\": \"A\", \"font\": {\"family\": \"X\"}, \"size\": 12, \"x\": 0, \"y\": 0}", + ); + let spec = Document::load_json(&json).unwrap().layers[0].shapes[0].text.clone().unwrap(); + assert_eq!(spec.line_spacing, 100.0); + assert!(spec.kerning && spec.flow.is_straight()); + assert_eq!(spec.font.style, "Regular"); + } + + #[test] + fn header_written() { + let json = sample().save_json().unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + let h = &v["stencilcraft"]; + assert_eq!(h["name"].as_str(), Some(FORMAT_NAME)); + assert_eq!(h["version"].as_u64(), Some(FORMAT_VERSION)); + } + + #[test] + fn junk_is_an_error_not_a_crash() { + assert!(Document::load_json("not json at all").is_err()); + assert!(matches!(Document::load_json("{}"), Err(DocError::NotAStencilcraftFile { .. }))); + } + + #[test] + fn future_version_is_rejected_calmly() { + let json = r#"{"stencilcraft": {"name": "stencilcraft", "version": 99}}"#; + assert!(matches!(Document::load_json(json), Err(DocError::FormatVersion { found: 99, expected: 1, .. }))); + } + + #[test] + fn missing_field_has_structure_error() { + let json = r#"{"stencilcraft": {"name": "stencilcraft", "version": 1}, + "board": {"width_mm": 1, "height_mm": 1}}"#; + assert!(matches!( + Document::load_json(json), + Err(DocError::Structure { ref message, .. }) if message.contains("kerf") + )); + } + + #[test] + fn images_round_trip_and_share_the_id_counter() { + let mut doc = canvas_sample(); + let id = doc + .push_image(RasterImage { + id: 0, + name: "photo".to_string(), + x_mm: 0.0, + y_mm: 0.0, + width_mm: 10.0, + height_mm: 5.0, + width_px: 2, + height_px: 1, + png_base64: "AAAA".into(), + }) + .unwrap(); + assert_eq!(id, 3); + let back = Document::load_json(&doc.save_json().unwrap()).unwrap(); + assert_eq!(back, doc); + let mut bad = doc.clone(); + bad.images[0].width_mm = -1.0; + assert!(bad.validate().is_err()); + } + + #[test] + fn push_shape_assigns_ids() { + let mut doc = Document::default(); + let s = doc.push_shape(0, rect_path(0.0, 0.0, 5.0, 5.0), CutStyle::Cut).unwrap(); + assert_eq!(s.id, 1); + let s2 = doc.push_shape(0, rect_path(5.0, 5.0, 5.0, 5.0), CutStyle::Cut).unwrap(); + assert_eq!(s2.id, 2); + assert!(matches!(doc.push_shape(9, rect_path(0.0, 0.0, 1.0, 1.0), CutStyle::Cut), Err(DocError::Model(_)))); + } + + fn canvas_sample() -> Document { + let mut doc = sample(); + doc.push_shape(0, rect_path(50.0, 50.0, 5.0, 5.0), CutStyle::Cut).unwrap(); + doc.board_setup.mat = Some("12x12".to_string()); + doc.pages.push(Page { + x_mm: 120.0, + y_mm: 0.0, + width_mm: 210.0, + height_mm: 297.0, + setup: PageSetup { name: "A4".to_string(), color: Some("#ffeedd".to_string()), ..PageSetup::default() }, + }); + doc.guides.push(Guide { axis: GuideAxis::Vertical, position_mm: 12.5, locked: true }); + doc.grid.spacing_mm = 5.0; + doc.grid.subdivisions = 4; + doc.snap.grid = true; + doc.layers[0].color = Some("#112233".to_string()); + doc.layers.push(Layer { template: true, locked: true, ..Layer::new("guide", CutStyle::Cut) }); + doc.groups = vec![ + Group { id: 1, name: "inner".to_string(), children: vec![GroupChild::Shape(1)] }, + Group { id: 2, name: String::new(), children: vec![GroupChild::Group(1), GroupChild::Shape(2)] }, + ]; + doc.meta.title = "Card".to_string(); + doc.meta.notes = "for mum".to_string(); + doc.meta.thumbnail_svg = Some(doc.render_thumbnail(4096)); + doc + } + + #[test] + fn canvas_fields_round_trip() { + let doc = canvas_sample(); + let back = Document::load_json(&doc.save_json().unwrap()).unwrap(); + assert_eq!(doc, back); + assert_eq!(back.group_chain(GroupChild::Shape(1)), vec![2, 1]); + assert_eq!(back.group_shapes(2), vec![1, 2]); + } + + #[test] + fn plain_documents_save_without_canvas_keys() { + let json = sample().save_json().unwrap(); + for key in ["pages", "guides", "grid", "snap", "groups", "meta", "board-setup", "template"] { + assert!(!json.contains(key), "{key} written for a plain document"); + } + } + + #[test] + fn version_1_files_without_canvas_fields_still_load() { + for name in ["starter", "poster", "stencil-frame"] { + let path = format!("{}/../../examples/{name}.stencilcraft", env!("CARGO_MANIFEST_DIR")); + let doc = load_from(&path).unwrap(); + assert!(doc.pages.is_empty() && doc.groups.is_empty() && doc.guides.is_empty()); + assert_eq!(doc.grid, Grid::default()); + assert!(doc.layers.iter().all(|l| !l.template && !l.locked)); + } + } + + #[test] + fn dangling_group_members_are_pruned_not_fatal() { + let mut doc = canvas_sample(); + doc.layers[0].shapes.retain(|s| s.id != 1); + // Group 1 loses its only member and disappears; group 2 keeps shape 2. + let back = Document::load_json(&doc.save_json().unwrap()).unwrap(); + assert_eq!(back.groups.len(), 1); + assert_eq!(back.groups[0].children, vec![GroupChild::Shape(2)]); + } + + #[test] + fn bad_canvas_data_is_rejected_calmly() { + type DocumentMutation = Box; + let cases: Vec = vec![ + Box::new(|d| d.groups[0].children.push(GroupChild::Group(2))), + Box::new(|d| d.groups[1].children.push(GroupChild::Shape(1))), + Box::new(|d| d.groups[0].id = 2), + Box::new(|d| d.pages[0].width_mm = f64::NAN), + Box::new(|d| d.guides[0].position_mm = f64::INFINITY), + Box::new(|d| d.grid.subdivisions = 0), + Box::new(|d| d.layers[0].color = Some("red".to_string())), + Box::new(|d| d.meta.notes = "x".repeat(canvas::MAX_NOTES_BYTES + 1)), + Box::new(|d| d.snap.tolerance_mm = f64::NAN), + ]; + for (i, corrupt) in cases.iter().enumerate() { + let mut doc = canvas_sample(); + corrupt(&mut doc); + assert!(doc.validate().is_err(), "case {i} validated"); + assert!(doc.save_json().is_err(), "case {i} saved"); + } + // The same faults in a file are load errors. + let json = canvas_sample().save_json().unwrap().replace("#112233", "#zz"); + assert!(Document::load_json(&json).is_err()); + } + + #[test] + fn page_document_takes_the_shapes_on_that_page() { + let mut doc = canvas_sample(); + doc.push_shape(0, rect_path(130.0, 10.0, 10.0, 10.0), CutStyle::Cut).unwrap(); + let page = doc.page_document(1).unwrap(); + assert_eq!((page.board.width_mm, page.board.height_mm), (210.0, 297.0)); + assert_eq!(page.shape_count(), 1); + let b = page.layers[0].shapes[0].bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-9 && (b.y0 - 10.0).abs() < 1e-9); + assert_eq!(doc.page_document(0).unwrap().shape_count(), 2); + assert!(doc.page_document(5).is_none()); + } + + #[test] + fn thumbnail_respects_its_budget() { + let doc = canvas_sample(); + let full = doc.render_thumbnail(64 * 1024); + assert!(full.starts_with("")); + assert_eq!(full.matches("")); + } +} diff --git a/crates/stencilcraft-document/src/production.rs b/crates/stencilcraft-document/src/production.rs new file mode 100644 index 000000000..c03717c0b --- /dev/null +++ b/crates/stencilcraft-document/src/production.rs @@ -0,0 +1,206 @@ +//! Per-shape production metadata: what the machine does with a shape (operation, colour and +//! material keys for mats and cut groups), Attach groups, hidden contours, a manual mat, and the +//! retained source shapes of a non-destructive Combine or a Flatten. Everything defaults to +//! "unset" and is skipped when saving, so older files and files without production data are +//! unchanged. + +use kurbo::{Affine, BezPath, PathEl}; +use serde::{Deserialize, Serialize}; + +use crate::Shape; + +/// What the machine does with a shape. `None` on a shape means "follow its cut/engrave style". +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Operation { + /// Cut the outline (the default for cut shapes). + Cut, + /// Sticker kiss cut: through the face material only, the backing stays whole. + KissCut, + /// Sticker die cut: through the face material and the backing. + DieCut, + /// Draw the outline with a pen. + Draw, + /// Crease a fold line. + Score, + /// Engrave the line (the default for engrave shapes). + Engrave, + /// A dashed tear line. + Perforate, + /// Print on a home printer, then cut around the print using registration marks. + PrintThenCut, + /// Shown on the canvas only, never sent to the machine or printer. + Guide, +} + +/// A per-shape tool assignment from the send preview, overriding the group settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SendAssign { + /// Every line of the shape with tool 1. + Tool1, + /// Only the shape's outer outline with tool 1 (inner contours are skipped). + Tool1Outline, + Tool2, + Tool2Outline, + /// Leave the shape out of the job. + Ignore, +} + +/// A Combine's shape operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CombineOp { + Unite, + /// The back-most member minus every other member. + Subtract, + Intersect, + /// Area covered by an odd number of members. + Exclude, +} + +/// Why a shape keeps its sources. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind", content = "operation")] +pub enum GroupKind { + /// Non-destructive Combine: the path is the operation's result; Uncombine restores the + /// members. + Combine(CombineOp), + /// Flatten: the members are the print artwork, the path is its cut outline. + Flatten, +} + +/// The shapes a Combine or Flatten replaced, kept so the action can be undone later. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SourceGroup { + pub kind: GroupKind, + pub members: Vec, + /// The grouped shape's path when the group was made. Comparing it with the current path + /// tells how the group was moved, rotated or scaled since, so the members follow. + pub path: BezPath, +} + +impl SourceGroup { + /// The members moved the way the grouped shape moved since grouping (`current` is its + /// path now). An edit that is not an affine map of the old outline (node edits) falls back + /// to mapping the old bounds onto the new ones. + pub fn members_now(&self, current: &BezPath) -> Vec { + let map = fit_affine(&self.path, current).unwrap_or_else(|| bounds_map(&self.path, current)); + self.members + .iter() + .map(|m| { + let mut m = m.clone(); + m.path = map * m.path; + m + }) + .collect() + } +} + +fn endpoints(path: &BezPath) -> Vec { + path.elements().iter().filter_map(|el| el.end_point()).collect() +} + +/// The affine map taking `old` onto `new` point for point, when the two have the same +/// structure and one exists (within 0.01 mm). +fn fit_affine(old: &BezPath, new: &BezPath) -> Option { + let (a, b) = (endpoints(old), endpoints(new)); + if a.len() != b.len() || a.len() < 3 { + return None; + } + let p0 = *a.first()?; + // The point farthest from p0, then the one making the largest triangle. + let i = (0..a.len()).max_by(|&i, &j| (a[i] - p0).hypot2().total_cmp(&(a[j] - p0).hypot2()))?; + let j = (0..a.len()).max_by(|&j, &k| (a[i] - p0).cross(a[j] - p0).abs().total_cmp(&(a[i] - p0).cross(a[k] - p0).abs()))?; + let (u, v) = (a[i] - p0, a[j] - p0); + let det = u.cross(v); + if det.abs() < 1e-9 { + // Collinear outline (or no movement information): a pure translation still works. + let t = Affine::translate(*b.first()? - p0); + return a.iter().zip(&b).all(|(p, q)| (t * *p - *q).hypot() < 0.01).then_some(t); + } + let q0 = *b.first()?; + let (s, t) = (b[i] - q0, b[j] - q0); + // M maps u→s and v→t: M = [s t]·[u v]⁻¹. + let inv = [v.y / det, -v.x / det, -u.y / det, u.x / det]; + let m = [s.x * inv[0] + t.x * inv[2], s.y * inv[0] + t.y * inv[2], s.x * inv[1] + t.x * inv[3], s.y * inv[1] + t.y * inv[3]]; + let linear = Affine::new([m[0], m[1], m[2], m[3], 0.0, 0.0]); + let map = Affine::translate(q0.to_vec2()) * linear * Affine::translate(-p0.to_vec2()); + a.iter().zip(&b).all(|(p, q)| (map * *p - *q).hypot() < 0.01).then_some(map) +} + +fn bounds_map(old: &BezPath, new: &BezPath) -> Affine { + use kurbo::Shape as _; + let (a, b) = (old.bounding_box(), new.bounding_box()); + let sx = if a.width() > 1e-9 { b.width() / a.width() } else { 1.0 }; + let sy = if a.height() > 1e-9 { b.height() / a.height() } else { 1.0 }; + Affine::translate((b.x0, b.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-a.x0, -a.y0)) +} + +/// Production metadata of one shape. Every field is optional. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ShapeProduction { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + /// Canonical `#rrggbb` line colour: the cut-by-line-colour key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_color: Option, + /// Canonical `#rrggbb` fill colour: the print colour and cut-by-fill-colour key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill_color: Option, + /// Material name; shapes on different materials go on different mats. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub material: Option, + /// Attach group: shapes sharing it keep their relative positions on one mat. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attach: Option, + /// Manual mat: the piece goes on this mat index instead of its automatic one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mat: Option, + /// Tool assignment from the send preview. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub send: Option, + /// Contours (subpath indices) hidden by Contour: kept in the path, left out of exports. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hidden_contours: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option>, +} + +impl ShapeProduction { + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +impl Shape { + /// The path the machine sees: the shape's path without the contours Contour hid. + pub fn cut_path(&self) -> std::borrow::Cow<'_, BezPath> { + if self.production.hidden_contours.is_empty() { + return std::borrow::Cow::Borrowed(&self.path); + } + let mut out = BezPath::new(); + let mut contour = 0usize; + let mut started = false; + for el in self.path.elements() { + if matches!(el, PathEl::MoveTo(_)) { + if started { + contour += 1; + } + started = true; + } + if !self.production.hidden_contours.contains(&contour) { + out.push(*el); + } + } + std::borrow::Cow::Owned(out) + } + + /// The shape's operation, falling back to its cut/engrave style. + pub fn operation(&self) -> Operation { + self.production.operation.unwrap_or(match self.style { + crate::CutStyle::Cut => Operation::Cut, + crate::CutStyle::Engrave => Operation::Engrave, + }) + } +} diff --git a/crates/stencilcraft-format/README.md b/crates/stencilcraft-format/README.md index 2391d3c56..c5b32582d 100644 --- a/crates/stencilcraft-format/README.md +++ b/crates/stencilcraft-format/README.md @@ -3,8 +3,10 @@ Reader and writer for the versioned `.stencilcraft` JSON file. The JSON header is `{"stencilcraft":{"name":"stencilcraft","version":1}}`; the document fields share the top-level object with the header. `DocumentFields` owns field names, flattening, required fields, -version 1 defaults, and omitted optional members. Its values remain JSON so the consuming app -owns the domain model, geometry validation, and field-to-model mapping. +version 1 defaults, and omission of default canvas members. Its type parameters let consumers use +their typed model fields directly; `File::>::read_typed` and +`to_json_pretty` handle the shared schema and JSON conversion. StencilCraft's typed model and +domain validation live in `craft-stencilcraft-document`. The crate does not open files or set a file-size policy. Callers perform bounded file I/O before calling `File::read`. @@ -13,10 +15,11 @@ calling `File::read`. craft-stencilcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "" } ``` -`File::read` parses JSON, validates the name and exact supported version, reads `DocumentFields`, -and ignores unknown members for version 1 compatibility. `File::new` and `to_json_pretty` write -the current header and flattened fields. Absent `next-shape-id` defaults to 1 and absent `images` -defaults to an empty list, matching early version 1 files. +`File::read` parses JSON into the default JSON-valued schema. For typed models, use +`File::>::read_typed`; both readers validate the name and exact supported +version and ignore unknown members for version 1 compatibility. `File::new` and `to_json_pretty` +write the current header and flattened fields. Absent `next-shape-id` defaults to 1 and absent +`images` defaults to an empty list, matching early version 1 files. `cargo test -p craft-stencilcraft-format` covers round-trip wire shape, version 1 defaults, unknown members, wrong and missing headers, unsupported versions, malformed JSON, and missing From 1f3be111b8dc0b1d7d05195357df0889b5b507fe Mon Sep 17 00:00:00 2001 From: alehel Date: Fri, 13 Nov 2026 09:56:22 +0100 Subject: [PATCH 029/118] craft-audio: drop a short last SBR patch before the patch limit A header whose sixth patch is short (96 kHz, start 3, stop 9) was rejected, though that patch is dropped and five remain. Co-Authored-By: Claude Opus 5.5 --- crates/audio/src/aac/sbr/freq.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/audio/src/aac/sbr/freq.rs b/crates/audio/src/aac/sbr/freq.rs index d12fbdc02..7bb372dc4 100644 --- a/crates/audio/src/aac/sbr/freq.rs +++ b/crates/audio/src/aac/sbr/freq.rs @@ -211,8 +211,9 @@ fn patches(master: &[usize], k0: usize, kx: usize, m: usize, fs: u32) -> Option< let (mut msb, mut usb) = (k0, kx); let mut out: Vec<(usize, usize)> = Vec::new(); // each pass either adds a patch or lowers `msb` to `kx`; a stream that does neither twice - // running cannot finish - for _ in 0..2 * (MAX_PATCHES + 1) { + // running cannot finish. One patch over the limit may still be a short last one that is + // dropped below. + for _ in 0..2 * (MAX_PATCHES + 2) { // the highest master border the patch can reach from below k0 let mut j = k + 1; let (sb, odd) = loop { @@ -227,7 +228,7 @@ fn patches(master: &[usize], k0: usize, kx: usize, m: usize, fs: u32) -> Option< if count > 0 { let start = (k0 - odd).checked_sub(count)?; out.push((start, count)); - if out.len() > MAX_PATCHES { + if out.len() > MAX_PATCHES + 1 { return None; } (usb, msb) = (sb, sb); @@ -241,7 +242,7 @@ fn patches(master: &[usize], k0: usize, kx: usize, m: usize, fs: u32) -> Option< if out.len() > 1 && out.last().is_some_and(|p| p.1 < 3) { out.pop(); } - return (!out.is_empty()).then_some(out); + return (!out.is_empty() && out.len() <= MAX_PATCHES).then_some(out); } } None @@ -350,6 +351,15 @@ mod tests { assert_eq!((t.k0, t.kx, t.m), (32, 32, 32)); } + /// At 96 kHz this header builds patches of 6, 6, 6, 6, 5 and 2 channels: the short sixth is + /// dropped, as any short last patch is, and the five left are within the limit. + #[test] + fn a_short_sixth_patch_is_dropped_before_the_limit() { + let t = FreqTables::new(&header(3, 9, 0, 0, false, 2), 96_000).unwrap(); + assert_eq!((t.k0, t.kx + t.m), (8, 39)); + assert_eq!(t.patches.iter().map(|p| p.1).collect::>(), [6, 6, 6, 6, 5]); + } + /// A typical 44.1 kHz header: start channel 12 + offset, stop near 64, three noise bands. #[test] fn a_44k_header_has_the_expected_layout() { From b373553e988b9f8ff4ac2c09a98a80fc94d1620b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:54:43 -0500 Subject: [PATCH 030/118] Add shared vector path import APIs --- Cargo.lock | 372 +++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + crates/dxf/Cargo.toml | 1 + crates/dxf/src/lib.rs | 1 + crates/dxf/src/paths.rs | 218 +++++++++++++++++++++++ crates/eps/Cargo.toml | 8 + crates/eps/src/lib.rs | 2 + crates/eps/src/paths.rs | 75 ++++++++ crates/pdf/Cargo.toml | 2 + crates/pdf/src/lib.rs | 2 + crates/pdf/src/paths.rs | 127 ++++++++++++++ 11 files changed, 801 insertions(+), 9 deletions(-) create mode 100644 crates/dxf/src/paths.rs create mode 100644 crates/eps/src/paths.rs create mode 100644 crates/pdf/src/paths.rs diff --git a/Cargo.lock b/Cargo.lock index cd280f90c..a0ad1277b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1384,6 +1384,7 @@ version = "0.1.0" dependencies = [ "craft-codepage", "encoding_rs", + "kurbo 0.13.1", "log", "thiserror 2.0.21", ] @@ -1400,6 +1401,12 @@ dependencies = [ [[package]] name = "craft-eps" version = "0.1.0" +dependencies = [ + "kurbo 0.13.1", + "thiserror 2.0.21", + "vectorcraft-doc", + "vectorcraft-eps", +] [[package]] name = "craft-epub" @@ -1954,6 +1961,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "hayro-interpret", + "hayro-syntax", + "krilla", + "thiserror 2.0.21", +] + [[package]] name = "craft-pdn" version = "0.1.0" @@ -2010,7 +2028,7 @@ name = "craft-plate-test" version = "0.1.0" dependencies = [ "craft-cms", - "craft-pdf", + "craft-pdf 0.1.0", "hayro-syntax", "serde", "serde_json", @@ -2071,7 +2089,7 @@ name = "craft-prepress" version = "0.1.0" dependencies = [ "craft-cms", - "craft-pdf", + "craft-pdf 0.1.0", "craft-plate-test", "kurbo 0.13.1", "linesweeper", @@ -3442,7 +3460,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types 0.12.7", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -3670,6 +3688,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -3968,7 +3992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -3985,7 +4009,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -4160,6 +4184,28 @@ dependencies = [ "smallvec", ] +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -4168,7 +4214,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -4177,7 +4223,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -6085,6 +6131,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + [[package]] name = "ordered-float" version = "5.5.0" @@ -7035,6 +7090,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types 0.12.7", + "once_cell", +] + [[package]] name = "read-fonts" version = "0.44.0" @@ -7053,7 +7119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "font-types 0.12.7", "once_cell", "yoke", @@ -8203,6 +8269,16 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -8646,6 +8722,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -9171,7 +9256,7 @@ dependencies = [ "base64 0.23.1", "data-url", "fontdb", - "harfrust", + "harfrust 0.12.0", "imagesize 0.15.0", "kurbo 0.13.1", "log", @@ -9218,6 +9303,221 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vectorcraft-brush" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "serde", + "serde_json", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-geom", +] + +[[package]] +name = "vectorcraft-color" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "moxcms", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "vectorcraft-doc" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "chrono", + "hayro-ccitt", + "image", + "kurbo 0.13.1", + "serde", + "serde_json", + "thiserror 2.0.21", + "tiff", + "vectorcraft-color", + "vectorcraft-geom", + "zune-jpeg", +] + +[[package]] +name = "vectorcraft-effects" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "image", + "kurbo 0.13.1", + "serde_json", + "vectorcraft-doc", + "vectorcraft-geom", + "vectorcraft-pathops", + "vectorcraft-plugins", + "vectorcraft-text", + "vectorcraft-three-d", +] + +[[package]] +name = "vectorcraft-eps" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "flate2", + "hayro-ccitt", + "image", + "kurbo 0.13.1", + "memchr", + "ruzstd", + "serde_json", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-metafile", + "vectorcraft-pdf", + "vectorcraft-render", + "vectorcraft-text", +] + +[[package]] +name = "vectorcraft-geom" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "vectorcraft-metafile" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "image", + "kurbo 0.13.1", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", +] + +[[package]] +name = "vectorcraft-pathops" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "kurbo 0.13.1", + "linesweeper", + "thiserror 2.0.21", + "vectorcraft-geom", +] + +[[package]] +name = "vectorcraft-pdf" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "aes 0.8.4", + "chrono", + "craft-pdf 0.1.0 (git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0)", + "flate2", + "getrandom 0.4.3", + "image", + "jpeg-encoder 0.6.1", + "kurbo 0.13.1", + "log", + "md-5 0.10.6", + "roxmltree 0.21.2", + "serde", + "sha2 0.10.9", + "skrifa 0.47.0", + "smallvec", + "thiserror 2.0.21", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", + "weezl 0.2.1", +] + +[[package]] +name = "vectorcraft-plugins" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-geom", + "wasmi", +] + +[[package]] +name = "vectorcraft-render" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "flate2", + "gif", + "image", + "jpeg-encoder 0.6.1", + "log", + "png 0.18.1", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", + "vello_common 0.2.0", + "vello_cpu 0.2.0", + "weezl 0.2.1", + "winsafe", +] + +[[package]] +name = "vectorcraft-text" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "harfrust 0.13.3", + "icu_segmenter", + "kurbo 0.13.1", + "log", + "skrifa 0.47.0", + "subsetter", + "unicode-bidi", + "unicode-general-category", + "unicode-script", + "unicode-segmentation", + "vectorcraft-doc", + "vectorcraft-geom", + "vello_cpu 0.2.0", + "windows-registry", +] + +[[package]] +name = "vectorcraft-three-d" +version = "0.18.0" +source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e" +dependencies = [ + "vectorcraft-geom", + "vectorcraft-pathops", +] + [[package]] name = "vello_common" version = "0.0.8" @@ -9288,9 +9588,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", + "crossbeam-channel", "glifo 0.3.0", "hashbrown 0.17.1", + "ordered-channel", "png 0.18.1", + "rayon", + "thread_local", "vello_common 0.2.0", ] @@ -9448,6 +9752,56 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" +[[package]] +name = "wasmi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 46f2824e5..6bc7b282f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } craft-file-formats = { path = "crates/file-formats" } +vectorcraft-doc = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } +vectorcraft-eps = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-dpx = { path = "crates/dpx" } diff --git a/crates/dxf/Cargo.toml b/crates/dxf/Cargo.toml index 70c4f1234..11de1c377 100644 --- a/crates/dxf/Cargo.toml +++ b/crates/dxf/Cargo.toml @@ -15,3 +15,4 @@ craft-codepage = { workspace = true } thiserror = { workspace = true } log = "0.4" encoding_rs = "0.8.35" +kurbo = { workspace = true } diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 0109e9040..4cf3e46e9 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -11,6 +11,7 @@ use std::io::Write as _; pub mod model; +pub mod paths; mod version; pub use version::Version; diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs new file mode 100644 index 000000000..045986b99 --- /dev/null +++ b/crates/dxf/src/paths.rs @@ -0,0 +1,218 @@ +//! Bounded conversion of common planar DXF entities into neutral Bézier paths. +//! +//! This is geometry extraction, not a full CAD scene renderer: unsupported entities such as +//! text, dimensions, meshes, and block inserts are reported as warnings and omitted. + +use kurbo::{BezPath, Point, Rect, Shape as _}; + +/// Limits on the number of paths returned by [`import`]. Parsing also uses the crate's tag cap. +pub const MAX_PATHS: usize = 200_000; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("DXF parse failed: {0}")] + Parse(#[from] crate::DxfError), + #[error("DXF contains more than {MAX_PATHS} paths")] + TooManyPaths, + #[error("DXF contains a non-finite path coordinate")] + NonFinite, +} + +/// Geometry in source DXF coordinates. `unit_to_mm` maps a source coordinate to millimetres; +/// unitless drawings use `1.0`, as the coordinates are then interpreted as millimetres. +#[derive(Debug, Clone)] +pub struct Imported { + pub paths: Vec, + pub bounds: Option, + pub unit_to_mm: f64, + pub warnings: Vec, +} + +/// Parse bounded DXF input and extract visible planar linework. +pub fn import(bytes: &[u8]) -> Result { + let tags = crate::parse_with_limit(bytes, crate::max_tags())?; + let drawing = crate::model::Document::read(&tags); + let unit_to_mm = units_to_mm(drawing.header.insunits(), drawing.header.measurement()); + let mut paths = Vec::new(); + let mut warnings = Vec::new(); + for record in &drawing.entities { + let entity = crate::model::Entity::read(record); + if !entity.common.visible || record.paper_space() { + continue; + } + let converted = entity_paths(&entity.kind, entity.common.extrusion); + if converted.iter().flat_map(|path| path.elements()).any(|element| { + let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); + match *element { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => !finite(p), + kurbo::PathEl::QuadTo(a, b) => !finite(a) || !finite(b), + kurbo::PathEl::CurveTo(a, b, c) => !finite(a) || !finite(b) || !finite(c), + kurbo::PathEl::ClosePath => false, + } + }) { + return Err(Error::NonFinite); + } + if converted.is_empty() { + continue; + } + if paths.len().saturating_add(converted.len()) > MAX_PATHS { + return Err(Error::TooManyPaths); + } + paths.extend(converted); + } + if drawing.skipped.iter().any(|(_, n)| *n > 0) { + warnings.push("some unsupported or stray DXF records were skipped".into()); + } + let bounds = paths.iter().map(|path| path.bounding_box()).reduce(|a, b| a.union(b)); + Ok(Imported { paths, bounds, unit_to_mm, warnings }) +} + +fn units_to_mm(code: i64, measurement: Option) -> f64 { + match code { + 0 if measurement == Some(0) => 25.4, + 1 => 25.4, + 2 => 304.8, + 3 => 1_609_344.0, + 5 => 10.0, + 6 => 1_000.0, + 7 => 1_000_000.0, + 8 => 0.000_025_4, + 9 => 0.0254, + 10 => 914.4, + 11 => 1e-7, + 12 => 1e-6, + 13 => 0.001, + 14 => 100.0, + 15 => 10_000.0, + 16 => 100_000.0, + 17 => 1e9, + 4 | _ => 1.0, + } +} + +fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> Vec { + use crate::model::EntityKind as E; + match kind { + E::Line { start, end } => vec![line(*start, *end)], + E::Circle { center, radius } if radius.is_finite() && *radius > 0.0 => { + vec![circle(ocs(normal, *center), *radius)] + } + E::Arc { center, radius, start_angle, end_angle } + if radius.is_finite() && *radius > 0.0 && start_angle.is_finite() && end_angle.is_finite() => + { + vec![arc(ocs(normal, *center), *radius, start_angle.to_radians(), end_angle.to_radians())] + } + E::LwPolyline(poly) => vec![polyline(poly.vertices.iter().map(|v| (ocs(normal, [v.x, v.y, poly.elevation]), v.bulge)), poly.closed)], + E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => { + vec![polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (ocs(normal, v.location), v.bulge)), poly.closed())] + } + E::Ellipse { center, major, ratio, start, end } if ratio.is_finite() && *ratio > 0.0 => { + vec![ellipse(*center, *major, *ratio, *start, *end)] + } + _ => Vec::new(), + } +} + +fn ocs(normal: crate::model::Point, point: crate::model::Point) -> Point { + let p = crate::model::ocs_to_wcs(normal, point); + Point::new(p[0], p[1]) +} + +fn line(start: crate::model::Point, end: crate::model::Point) -> BezPath { + let mut path = BezPath::new(); + path.move_to((start[0], start[1])); + path.line_to((end[0], end[1])); + path +} + +fn polyline(vertices: impl IntoIterator, closed: bool) -> BezPath { + let vertices = vertices.into_iter().collect::>(); + let mut path = BezPath::new(); + let Some((first, _)) = vertices.first().copied() else { return path }; + path.move_to(first); + for i in 1..vertices.len() { + let (a, bulge) = vertices[i - 1]; + let (b, _) = vertices[i]; + if bulge.is_finite() && bulge.abs() > 1e-12 { + append_bulge(&mut path, a, b, bulge); + } else { + path.line_to(b); + } + } + if closed && vertices.len() > 1 { + let (a, bulge) = *vertices.last().unwrap_or(&(first, 0.0)); + if bulge.is_finite() && bulge.abs() > 1e-12 { + append_bulge(&mut path, a, first, bulge); + } else { + path.line_to(first); + } + path.close_path(); + } + path +} + +fn append_bulge(path: &mut BezPath, a: Point, b: Point, bulge: f64) { + let chord = b - a; + let length = chord.hypot(); + if !(length.is_finite() && length > 0.0) { + path.line_to(b); + return; + } + let center = a + chord * 0.5 + kurbo::Vec2::new(-chord.y, chord.x) * ((1.0 - bulge * bulge) / (4.0 * bulge)); + let start = (a.y - center.y).atan2(a.x - center.x); + let sweep = 4.0 * bulge.atan(); + let radius = length * (1.0 + bulge * bulge) / (4.0 * bulge.abs()); + arc_segments(path, center, start, sweep, radius); +} + +fn circle(center: Point, radius: f64) -> BezPath { + let mut path = BezPath::new(); + path.move_to((center.x + radius, center.y)); + arc_segments(&mut path, center, 0.0, std::f64::consts::TAU, radius); + path.close_path(); + path +} + +fn arc(center: Point, radius: f64, start: f64, end: f64) -> BezPath { + let mut path = BezPath::new(); + path.move_to((center.x + radius * start.cos(), center.y + radius * start.sin())); + let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU }; + arc_segments(&mut path, center, start, sweep, radius); + path +} + +fn arc_segments(path: &mut BezPath, center: Point, start: f64, sweep: f64, radius: f64) { + let count = (sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil().max(1.0) as usize; + let step = sweep / count as f64; + for i in 0..count { + let a = start + step * i as f64; + let b = a + step; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let p1 = Point::new(center.x + radius * (a.cos() - k * a.sin()), center.y + radius * (a.sin() + k * a.cos())); + let p2 = Point::new(center.x + radius * (b.cos() + k * b.sin()), center.y + radius * (b.sin() - k * b.cos())); + let p3 = Point::new(center.x + radius * b.cos(), center.y + radius * b.sin()); + path.curve_to(p1, p2, p3); + } +} + +fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, start: f64, end: f64) -> BezPath { + let (cx, cy) = (center[0], center[1]); + let (ax, ay) = (major[0], major[1]); + let (bx, by) = (-ay * ratio, ax * ratio); + let point = |t: f64| Point::new(cx + ax * t.cos() + bx * t.sin(), cy + ay * t.cos() + by * t.sin()); + let mut path = BezPath::new(); + path.move_to(point(start)); + let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU }; + let count = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + let step = sweep / count as f64; + for i in 0..count { + let a = start + step * i as f64; + let b = a + step; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let derivative = |t: f64| Point::new(-ax * t.sin() + bx * t.cos(), -ay * t.sin() + by * t.cos()); + let p1 = point(a) + derivative(a).to_vec2() * k; + let p2 = point(b) - derivative(b).to_vec2() * k; + path.curve_to(p1, p2, point(b)); + } + path +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index fbe69c26f..6984fb576 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -11,3 +11,11 @@ repository.workspace = true workspace = true [dependencies] +kurbo = { workspace = true, optional = true } +vectorcraft-doc = { workspace = true, optional = true } +vectorcraft-eps = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } + +[features] +default = [] +paths = ["dep:kurbo", "dep:thiserror", "dep:vectorcraft-doc", "dep:vectorcraft-eps"] diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index acffdfcf2..6e5e91d93 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -18,6 +18,8 @@ pub mod ascii85; mod container; mod dsc; +#[cfg(feature = "paths")] +pub mod paths; mod preview; pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap}; diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs new file mode 100644 index 000000000..241c812f7 --- /dev/null +++ b/crates/eps/src/paths.rs @@ -0,0 +1,75 @@ +//! Neutral path extraction for EPS and PostScript files. +//! +//! This facade keeps the PostScript interpreter and its format decisions in shared dependencies; +//! applications receive Bézier paths, page bounds and warnings for their own document adapters. + +use kurbo::BezPath; + +/// Limits on the extracted geometry. The interpreter has its own execution and allocation caps. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_input_bytes: usize, + pub max_paths: usize, + pub max_path_elements: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_input_bytes: 64 * 1024 * 1024, max_paths: 200_000, max_path_elements: 2_000_000 } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("PostScript input exceeds {limit} bytes")] + InputLimit { limit: usize }, + #[error("PostScript import failed: {0}")] + Parse(String), + #[error("PostScript path output exceeds configured limit")] + OutputLimit, +} + +/// First-page path geometry in PostScript points, plus the page size and import notes. +#[derive(Debug, Clone)] +pub struct Imported { + pub paths: Vec, + pub page_points: (f64, f64), + pub warnings: Vec, + pub preview_fallback: bool, +} + +/// Interpret EPS/PostScript bytes and return their editable vector path geometry. +pub fn import(bytes: &[u8], limits: Limits) -> Result { + if bytes.len() > limits.max_input_bytes { + return Err(Error::InputLimit { limit: limits.max_input_bytes }); + } + let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; + let page_points = source.document.artboards.first().map_or((612.0, 792.0), |board| (board.rect.width(), board.rect.height())); + let mut paths = Vec::new(); + let mut total_elements = 0usize; + for layer in &source.document.layers { + collect(layer, &mut paths, &mut total_elements, limits)?; + } + Ok(Imported { paths, page_points, warnings: source.warnings, preview_fallback: source.preview }) +} + +fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mut usize, limits: Limits) -> Result<(), Error> { + if !node.visible { + return Ok(()); + } + if let Some(data) = node.path_data() { + let path = data.to_bezpath(); + if !path.elements().is_empty() { + *elements = elements.saturating_add(path.elements().len()); + if paths.len() >= limits.max_paths || *elements > limits.max_path_elements { + return Err(Error::OutputLimit); + } + paths.push(path); + } + } else if let Some(children) = node.children() { + for child in children { + collect(child, paths, elements, limits)?; + } + } + Ok(()) +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 193aeb9df..b370416ab 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -14,6 +14,7 @@ workspace = true default = [] read = ["dep:hayro-syntax", "dep:thiserror"] interpret = ["read", "dep:hayro-interpret"] +paths = ["interpret", "dep:kurbo"] raster = ["interpret", "dep:hayro", "dep:bytemuck"] write = ["dep:krilla", "dep:thiserror"] write-pdf = ["write", "krilla/pdf"] @@ -40,6 +41,7 @@ stringprep = { version = "0.1", optional = true } zeroize = { version = "1", optional = true } # PDF date strings in the object layer (`cos`). chrono = { workspace = true, optional = true } +kurbo = { workspace = true, optional = true } # On the web, a rewrite's fresh file identifier (`cos`) takes its entropy from the browser # (`crypto.getRandomValues`); native builds compile neither. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e052c7304..ed9f21a79 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -21,6 +21,8 @@ pub use link::safe_url; mod read; #[cfg(feature = "read")] pub use read::*; +#[cfg(feature = "paths")] +pub mod paths; #[cfg(feature = "raster")] pub mod raster; #[cfg(feature = "write")] diff --git a/crates/pdf/src/paths.rs b/crates/pdf/src/paths.rs new file mode 100644 index 000000000..bb51e2a0f --- /dev/null +++ b/crates/pdf/src/paths.rs @@ -0,0 +1,127 @@ +//! Bounded extraction of page vector paths from PDF content streams. +//! +//! Paths remain in PDF user-space points. Text glyphs and raster images are counted as omitted +//! content; clipping and paint are not applied. Applications own page selection and model mapping. + +use hayro_interpret::font::Glyph; +use hayro_interpret::{ + BlendMode, ClipPath, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, +}; +use kurbo::{Affine, BezPath, Rect}; + +/// Limits for vector path extraction. They bound the output model, not interpreter peak memory. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_input_bytes: usize, + pub max_pages: usize, + pub max_paths: usize, + pub max_path_elements: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_input_bytes: 64 * 1024 * 1024, max_pages: 1000, max_paths: 200_000, max_path_elements: 2_000_000 } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Open(#[from] crate::read::OpenError), + #[error(transparent)] + Interpret(#[from] crate::read::ExecutionError), + #[error("PDF contains more than {limit} pages")] + Pages { limit: usize }, + #[error("PDF path output exceeds configured limit")] + OutputLimit, +} + +/// Extracted paths for one PDF page, with its intersected crop box in user-space points. +#[derive(Debug, Clone)] +pub struct Page { + pub crop_box: Rect, + pub paths: Vec, + pub omitted_text: usize, + pub omitted_images: usize, + pub clips_seen: usize, +} + +/// Pages from one PDF. Paths from separate pages are never combined. +#[derive(Debug, Clone)] +pub struct Imported { + pub pages: Vec, + pub warnings: Vec, +} + +/// Open and interpret vector content using shared PDF parsing and interpreter APIs. +pub fn import(bytes: &[u8], limits: Limits) -> Result { + let options = crate::read::OpenOptions { max_input_bytes: limits.max_input_bytes, ..Default::default() }; + let pdf = crate::read::open_slice_with_options(bytes, None, &options)?; + if pdf.pages().len() > limits.max_pages { + return Err(Error::Pages { limit: limits.max_pages }); + } + let cache = InterpreterCache::new(); + let mut pages = Vec::with_capacity(pdf.pages().len()); + let mut total_paths = 0usize; + let mut total_elements = 0usize; + for page in pdf.pages().iter() { + let crop_box = page.intersected_crop_box().to_kurbo(); + let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), InterpreterSettings::default()); + let mut device = Collector::default(); + crate::read::interpret_page(page, &mut context, &mut device)?; + total_paths = total_paths.saturating_add(device.paths.len()); + total_elements = device.paths.iter().fold(total_elements, |n, path| n.saturating_add(path.elements().len())); + if total_paths > limits.max_paths || total_elements > limits.max_path_elements { + return Err(Error::OutputLimit); + } + pages.push(Page { + crop_box, + paths: device.paths, + omitted_text: device.omitted_text, + omitted_images: device.omitted_images, + clips_seen: device.clips_seen, + }); + } + let mut warnings = Vec::new(); + if pages.iter().any(|page| page.omitted_text > 0) { + warnings.push("text glyphs are not returned as paths".into()); + } + if pages.iter().any(|page| page.omitted_images > 0) { + warnings.push("raster images are not returned as paths".into()); + } + if pages.iter().any(|page| page.clips_seen > 0) { + warnings.push("clip paths are not applied to extracted geometry".into()); + } + Ok(Imported { pages, warnings }) +} + +#[derive(Default)] +struct Collector { + paths: Vec, + omitted_text: usize, + omitted_images: usize, + clips_seen: usize, +} + +impl Device<'_> for Collector { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) { + let path = transform * path.clone(); + if !path.is_empty() { + self.paths.push(path); + } + } + fn push_clip_path(&mut self, _: &ClipPath) { + self.clips_seen = self.clips_seen.saturating_add(1); + } + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) { + self.omitted_text = self.omitted_text.saturating_add(1); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) { + self.omitted_images = self.omitted_images.saturating_add(1); + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} From f80d86172b89a9bd9cfcb68ab272f55d0c1c512e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:01:33 -0500 Subject: [PATCH 031/118] Expose PostScript page bounds in path imports --- crates/eps/src/paths.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs index 241c812f7..adbe294f3 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -3,7 +3,7 @@ //! This facade keeps the PostScript interpreter and its format decisions in shared dependencies; //! applications receive Bézier paths, page bounds and warnings for their own document adapters. -use kurbo::BezPath; +use kurbo::{BezPath, Rect}; /// Limits on the extracted geometry. The interpreter has its own execution and allocation caps. #[derive(Debug, Clone, Copy)] @@ -33,7 +33,8 @@ pub enum Error { #[derive(Debug, Clone)] pub struct Imported { pub paths: Vec, - pub page_points: (f64, f64), + /// Page bounds in source PostScript points, including a nonzero or negative origin. + pub page_bounds: Rect, pub warnings: Vec, pub preview_fallback: bool, } @@ -44,13 +45,17 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::InputLimit { limit: limits.max_input_bytes }); } let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; - let page_points = source.document.artboards.first().map_or((612.0, 792.0), |board| (board.rect.width(), board.rect.height())); + let page_bounds = source + .document + .artboards + .first() + .map_or(Rect::new(0.0, 0.0, 612.0, 792.0), |board| Rect::new(board.rect.x0, board.rect.y0, board.rect.x1, board.rect.y1)); let mut paths = Vec::new(); let mut total_elements = 0usize; for layer in &source.document.layers { collect(layer, &mut paths, &mut total_elements, limits)?; } - Ok(Imported { paths, page_points, warnings: source.warnings, preview_fallback: source.preview }) + Ok(Imported { paths, page_bounds, warnings: source.warnings, preview_fallback: source.preview }) } fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mut usize, limits: Limits) -> Result<(), Error> { From 5082d8bd95807de5a851b6ab6ec3546fdf0ec5c4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:08:44 -0500 Subject: [PATCH 032/118] Add structured SVG scene writer --- crates/svg/src/write.rs | 146 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index 614642de5..ac884d8f2 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -30,6 +30,152 @@ use kurbo::{BezPath, PathEl, Point}; /// The SVG namespace. pub const SVG_NS: &str = "http://www.w3.org/2000/svg"; +/// A structured SVG scene. Applications provide geometry, styles and attributes; this module +/// owns the SVG element tree and serializes it with XML escaping. +#[derive(Clone, Debug, PartialEq)] +pub struct Scene { + pub width_mm: f64, + pub height_mm: f64, + pub view_box: [f64; 4], + pub elements: Vec, +} + +/// Structured SVG elements supported by the path-document writer. +#[derive(Clone, Debug, PartialEq)] +pub enum Element { + Group { attributes: Vec, children: Vec }, + Path { attributes: Vec, path: BezPath }, + Rect { attributes: Vec, x: f64, y: f64, width: f64, height: f64 }, + Text { attributes: Vec, x: f64, y: f64, text: String }, + Title(String), + Style(String), + NestedSvg { attributes: Vec, children: Vec }, +} + +/// An escaped SVG attribute. Element names and attribute names are API constants and must be +/// valid XML names; values are always escaped by the shared writer. +#[derive(Clone, Debug, PartialEq)] +pub struct Attribute { + pub name: String, + pub value: Value, +} + +/// Attribute values written by the shared serializer. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Text(String), + Number(f64), + Millimetres(f64), + ViewBox([f64; 4]), + TranslateScale { x: f64, y: f64, scale_x: f64, scale_y: f64 }, +} + +impl Attribute { + pub fn text(name: impl Into, value: impl Into) -> Self { + Self { name: name.into(), value: Value::Text(value.into()) } + } + pub fn number(name: impl Into, value: f64) -> Self { + Self { name: name.into(), value: Value::Number(value) } + } + pub fn millimetres(name: impl Into, value: f64) -> Self { + Self { name: name.into(), value: Value::Millimetres(value) } + } + pub fn view_box(bounds: [f64; 4]) -> Self { + Self { name: "viewBox".into(), value: Value::ViewBox(bounds) } + } + pub fn translate_scale(x: f64, y: f64, scale_x: f64, scale_y: f64) -> Self { + Self { name: "transform".into(), value: Value::TranslateScale { x, y, scale_x, scale_y } } + } +} + +impl Scene { + /// Serialize the scene as an SVG 1.1 document. + pub fn write(&self) -> String { + let mut writer = SvgWriter::new(); + writer + .root() + .attr("version", "1.1") + .attr("width", format!("{}mm", num(self.width_mm, 3))) + .attr("height", format!("{}mm", num(self.height_mm, 3))) + .attr("viewBox", view_box_data(self.view_box)) + .open(); + write_elements(&mut writer, &self.elements, 1); + writer.line(0); + writer.close(); + writer.finish() + } +} + +fn write_attributes<'a>(mut tag: craft_xml::Tag<'a>, attributes: &'a [Attribute]) -> craft_xml::Tag<'a> { + for attribute in attributes { + tag = match &attribute.value { + Value::Text(value) => tag.attr(&attribute.name, value), + Value::Number(value) => tag.attr(&attribute.name, num(*value, 3)), + Value::Millimetres(value) => tag.attr(&attribute.name, format!("{}mm", num(*value, 3))), + Value::ViewBox(bounds) => tag.attr(&attribute.name, view_box_data(*bounds)), + Value::TranslateScale { x, y, scale_x, scale_y } => { + tag.attr(&attribute.name, format!("translate({} {}) scale({} {})", num(*x, 3), num(*y, 3), num(*scale_x, 4), num(*scale_y, 4))) + } + }; + } + tag +} + +fn write_elements(writer: &mut SvgWriter, elements: &[Element], depth: usize) { + for element in elements { + writer.line(depth); + match element { + Element::Group { attributes, children } => { + write_attributes(writer.tag("g"), attributes).open(); + write_elements(writer, children, depth + 1); + writer.line(depth); + writer.close(); + } + Element::Path { attributes, path } => { + let attributes = attributes.clone(); + write_attributes(writer.tag("path").attr("d", path_data(path)), &attributes).empty(); + } + Element::Rect { attributes, x, y, width, height } => { + let mut attrs = vec![ + Attribute::number("x", *x), + Attribute::number("y", *y), + Attribute::number("width", *width), + Attribute::number("height", *height), + ]; + attrs.extend(attributes.iter().cloned()); + write_attributes(writer.tag("rect"), &attrs).empty(); + } + Element::Text { attributes, x, y, text } => { + let mut attrs = vec![Attribute::number("x", *x), Attribute::number("y", *y)]; + attrs.extend(attributes.iter().cloned()); + write_attributes(writer.tag("text"), &attrs).open(); + writer.text(text); + writer.close(); + } + Element::Title(text) => { + writer.tag("title").open(); + writer.text(text); + writer.close(); + } + Element::Style(css) => { + writer.tag("style").open(); + writer.text(css); + writer.close(); + } + Element::NestedSvg { attributes, children } => { + write_attributes(writer.tag("svg"), attributes).open(); + write_elements(writer, children, depth + 1); + writer.line(depth); + writer.close(); + } + } + } +} + +fn view_box_data(bounds: [f64; 4]) -> String { + format!("{} {} {} {}", num(bounds[0], 3), num(bounds[1], 3), num(bounds[2], 3), num(bounds[3], 3)) +} + /// A number written with a fixed count of decimals. A value that is not finite is written as /// zero, because SVG has no spelling for it. #[derive(Debug, Clone, Copy, PartialEq)] From 54f3a37edb4472349418cc4a503890ac49d9bf84 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:11:26 -0500 Subject: [PATCH 033/118] Extend shared vector format boundaries --- crates/dxf/src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index 045986b99..f34f0e62f 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -86,7 +86,7 @@ fn units_to_mm(code: i64, measurement: Option) -> f64 { 15 => 10_000.0, 16 => 100_000.0, 17 => 1e9, - 4 | _ => 1.0, + _ => 1.0, } } From 85ed941ac5a5c963949694a6a422390607ee5c1f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:30:17 -0500 Subject: [PATCH 034/118] Address vector import review findings --- crates/dxf/src/paths.rs | 32 ++++++++++++++++++++++++++++++-- crates/svg/src/write.rs | 6 +++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index f34f0e62f..1b57cec40 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -35,6 +35,7 @@ pub fn import(bytes: &[u8]) -> Result { let unit_to_mm = units_to_mm(drawing.header.insunits(), drawing.header.measurement()); let mut paths = Vec::new(); let mut warnings = Vec::new(); + let mut unsupported = 0usize; for record in &drawing.entities { let entity = crate::model::Entity::read(record); if !entity.common.visible || record.paper_space() { @@ -53,6 +54,17 @@ pub fn import(bytes: &[u8]) -> Result { return Err(Error::NonFinite); } if converted.is_empty() { + if matches!( + entity.kind, + crate::model::EntityKind::Other + | crate::model::EntityKind::Text(_) + | crate::model::EntityKind::MText(_) + | crate::model::EntityKind::Insert(_) + | crate::model::EntityKind::Dimension(_) + | crate::model::EntityKind::Hatch(_) + ) { + unsupported = unsupported.saturating_add(1); + } continue; } if paths.len().saturating_add(converted.len()) > MAX_PATHS { @@ -60,6 +72,9 @@ pub fn import(bytes: &[u8]) -> Result { } paths.extend(converted); } + if unsupported > 0 { + warnings.push(format!("{unsupported} visible DXF entities are unsupported and were omitted")); + } if drawing.skipped.iter().any(|(_, n)| *n > 0) { warnings.push("some unsupported or stray DXF records were skipped".into()); } @@ -67,9 +82,9 @@ pub fn import(bytes: &[u8]) -> Result { Ok(Imported { paths, bounds, unit_to_mm, warnings }) } -fn units_to_mm(code: i64, measurement: Option) -> f64 { +fn units_to_mm(code: i64, _measurement: Option) -> f64 { match code { - 0 if measurement == Some(0) => 25.4, + 0 => 1.0, 1 => 25.4, 2 => 304.8, 3 => 1_609_344.0, @@ -86,6 +101,13 @@ fn units_to_mm(code: i64, measurement: Option) -> f64 { 15 => 10_000.0, 16 => 100_000.0, 17 => 1e9, + 18 => 149_597_870_700.0, + 19 => 9.460_730_472_580_8e18, + 20 => 3.085_677_581_491_367e19, + 21 => 1200.0 / 3937.0 * 1000.0, + 22 => 1200.0 / 3937.0 * 1000.0 / 12.0, + 23 => 1200.0 / 3937.0 * 1000.0 * 3.0, + 24 => 1200.0 / 3937.0 * 1000.0 * 5280.0, _ => 1.0, } } @@ -183,6 +205,9 @@ fn arc(center: Point, radius: f64, start: f64, end: f64) -> BezPath { fn arc_segments(path: &mut BezPath, center: Point, start: f64, sweep: f64, radius: f64) { let count = (sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil().max(1.0) as usize; + if count > 4096 { + return; + } let step = sweep / count as f64; for i in 0..count { let a = start + step * i as f64; @@ -204,6 +229,9 @@ fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, path.move_to(point(start)); let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU }; let count = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize; + if count > 4096 { + return path; + } let step = sweep / count as f64; for i in 0..count { let a = start + step * i as f64; diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index ac884d8f2..664ce8f01 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -114,13 +114,17 @@ fn write_attributes<'a>(mut tag: craft_xml::Tag<'a>, attributes: &'a [Attribute] Value::Millimetres(value) => tag.attr(&attribute.name, format!("{}mm", num(*value, 3))), Value::ViewBox(bounds) => tag.attr(&attribute.name, view_box_data(*bounds)), Value::TranslateScale { x, y, scale_x, scale_y } => { - tag.attr(&attribute.name, format!("translate({} {}) scale({} {})", num(*x, 3), num(*y, 3), num(*scale_x, 4), num(*scale_y, 4))) + tag.attr(&attribute.name, format!("translate({} {}) scale({} {})", num(*x, 3), num(*y, 3), shortest(*scale_x), shortest(*scale_y))) } }; } tag } +fn shortest(value: f64) -> String { + if value.is_finite() { value.to_string() } else { "0".into() } +} + fn write_elements(writer: &mut SvgWriter, elements: &[Element], depth: usize) { for element in elements { writer.line(depth); From e3d76471f6c3bcd8e19fba308c62ec0050ae1b64 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:36:05 -0500 Subject: [PATCH 035/118] Fix vector import edge cases from review --- crates/dxf/src/paths.rs | 69 ++++++++++++++++++++++++++--------------- crates/eps/src/paths.rs | 49 +++++++++++++++++++++++------ crates/pdf/src/paths.rs | 44 +++++++++++++++++++++++--- crates/svg/src/write.rs | 11 +++++-- 4 files changed, 130 insertions(+), 43 deletions(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index 1b57cec40..01e555797 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -14,6 +14,8 @@ pub enum Error { Parse(#[from] crate::DxfError), #[error("DXF contains more than {MAX_PATHS} paths")] TooManyPaths, + #[error("DXF curve exceeds the subdivision limit")] + TooManySegments, #[error("DXF contains a non-finite path coordinate")] NonFinite, } @@ -41,6 +43,9 @@ pub fn import(bytes: &[u8]) -> Result { if !entity.common.visible || record.paper_space() { continue; } + if excessive_sweep(&entity.kind) { + return Err(Error::TooManySegments); + } let converted = entity_paths(&entity.kind, entity.common.extrusion); if converted.iter().flat_map(|path| path.elements()).any(|element| { let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); @@ -54,17 +59,7 @@ pub fn import(bytes: &[u8]) -> Result { return Err(Error::NonFinite); } if converted.is_empty() { - if matches!( - entity.kind, - crate::model::EntityKind::Other - | crate::model::EntityKind::Text(_) - | crate::model::EntityKind::MText(_) - | crate::model::EntityKind::Insert(_) - | crate::model::EntityKind::Dimension(_) - | crate::model::EntityKind::Hatch(_) - ) { - unsupported = unsupported.saturating_add(1); - } + unsupported = unsupported.saturating_add(1); continue; } if paths.len().saturating_add(converted.len()) > MAX_PATHS { @@ -117,27 +112,44 @@ fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> match kind { E::Line { start, end } => vec![line(*start, *end)], E::Circle { center, radius } if radius.is_finite() && *radius > 0.0 => { - vec![circle(ocs(normal, *center), *radius)] + vec![map_ocs_path(circle(Point::new(center[0], center[1]), *radius), normal, center[2])] } E::Arc { center, radius, start_angle, end_angle } if radius.is_finite() && *radius > 0.0 && start_angle.is_finite() && end_angle.is_finite() => { - vec![arc(ocs(normal, *center), *radius, start_angle.to_radians(), end_angle.to_radians())] + vec![map_ocs_path(arc(Point::new(center[0], center[1]), *radius, start_angle.to_radians(), end_angle.to_radians()), normal, center[2])] + } + E::LwPolyline(poly) => { + vec![map_ocs_path(polyline(poly.vertices.iter().map(|v| (Point::new(v.x, v.y), v.bulge)), poly.closed), normal, poly.elevation)] } - E::LwPolyline(poly) => vec![polyline(poly.vertices.iter().map(|v| (ocs(normal, [v.x, v.y, poly.elevation]), v.bulge)), poly.closed)], E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => { - vec![polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (ocs(normal, v.location), v.bulge)), poly.closed())] + let is_3d = poly.flags & 8 != 0; + let z = if is_3d { 0.0 } else { poly.elevation }; + let path = + polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (Point::new(v.location[0], v.location[1]), v.bulge)), poly.closed()); + vec![if is_3d { path } else { map_ocs_path(path, normal, z) }] } E::Ellipse { center, major, ratio, start, end } if ratio.is_finite() && *ratio > 0.0 => { - vec![ellipse(*center, *major, *ratio, *start, *end)] + vec![ellipse(*center, *major, normal, *ratio, *start, *end)] } _ => Vec::new(), } } -fn ocs(normal: crate::model::Point, point: crate::model::Point) -> Point { - let p = crate::model::ocs_to_wcs(normal, point); - Point::new(p[0], p[1]) +fn map_ocs_path(path: BezPath, normal: crate::model::Point, z: f64) -> BezPath { + let [x, y, n] = crate::model::ocs_axes(normal); + let transform = kurbo::Affine::new([x[0], y[0], x[1], y[1], n[0] * z, n[1] * z]); + transform * path +} + +fn excessive_sweep(kind: &crate::model::EntityKind) -> bool { + let cap = 4096.0 * std::f64::consts::FRAC_PI_2; + let over = |sweep: f64| !sweep.is_finite() || sweep.abs() > cap; + match kind { + crate::model::EntityKind::Arc { start_angle, end_angle, .. } => over((end_angle - start_angle).to_radians()), + crate::model::EntityKind::Ellipse { start, end, .. } => over(end - start), + _ => false, + } } fn line(start: crate::model::Point, end: crate::model::Point) -> BezPath { @@ -220,11 +232,14 @@ fn arc_segments(path: &mut BezPath, center: Point, start: f64, sweep: f64, radiu } } -fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, start: f64, end: f64) -> BezPath { - let (cx, cy) = (center[0], center[1]); - let (ax, ay) = (major[0], major[1]); - let (bx, by) = (-ay * ratio, ax * ratio); - let point = |t: f64| Point::new(cx + ax * t.cos() + bx * t.sin(), cy + ay * t.cos() + by * t.sin()); +fn ellipse(center: crate::model::Point, major: crate::model::Point, normal: crate::model::Point, ratio: f64, start: f64, end: f64) -> BezPath { + let minor = cross(normal, major); + let minor_len = minor.iter().map(|v| v * v).sum::().sqrt(); + if !(minor_len.is_finite() && minor_len > 0.0) { + return BezPath::new(); + } + let bx = [minor[0] / minor_len * ratio, minor[1] / minor_len * ratio, minor[2] / minor_len * ratio]; + let point = |t: f64| Point::new(center[0] + major[0] * t.cos() + bx[0] * t.sin(), center[1] + major[1] * t.cos() + bx[1] * t.sin()); let mut path = BezPath::new(); path.move_to(point(start)); let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU }; @@ -237,10 +252,14 @@ fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, let a = start + step * i as f64; let b = a + step; let k = 4.0 / 3.0 * (step / 4.0).tan(); - let derivative = |t: f64| Point::new(-ax * t.sin() + bx * t.cos(), -ay * t.sin() + by * t.cos()); + let derivative = |t: f64| Point::new(-major[0] * t.sin() + bx[0] * t.cos(), -major[1] * t.sin() + bx[1] * t.cos()); let p1 = point(a) + derivative(a).to_vec2() * k; let p2 = point(b) - derivative(b).to_vec2() * k; path.curve_to(p1, p2, point(b)); } path } + +fn cross(a: crate::model::Point, b: crate::model::Point) -> crate::model::Point { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs index adbe294f3..ad1f23793 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -45,24 +45,46 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::InputLimit { limit: limits.max_input_bytes }); } let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; - let page_bounds = source - .document - .artboards - .first() - .map_or(Rect::new(0.0, 0.0, 612.0, 792.0), |board| Rect::new(board.rect.x0, board.rect.y0, board.rect.x1, board.rect.y1)); + let section = crate::sections(bytes).map_err(|e| Error::Parse(e.to_string()))?; + let source_box = crate::Comments::read(section.postscript).page_box().unwrap_or([0.0, 0.0, 612.0, 792.0]); + let [llx, _lly, urx, ury] = source_box; + let page_bounds = Rect::new(llx, lly, urx, ury); let mut paths = Vec::new(); let mut total_elements = 0usize; + let mut clipped = false; + let mut omitted = false; for layer in &source.document.layers { - collect(layer, &mut paths, &mut total_elements, limits)?; + collect(layer, &mut paths, &mut total_elements, limits, &mut clipped, &mut omitted)?; } - Ok(Imported { paths, page_bounds, warnings: source.warnings, preview_fallback: source.preview }) + let inverse_page = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, llx, ury]); + for path in &mut paths { + *path = inverse_page * path.clone(); + } + let mut warnings = source.warnings; + if clipped { + warnings.push("EPS clipping groups are returned as unclipped artwork".into()); + } + if omitted { + warnings.push("visible EPS text or image content is not returned as paths".into()); + } + Ok(Imported { paths, page_bounds, warnings, preview_fallback: source.preview }) } -fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mut usize, limits: Limits) -> Result<(), Error> { +fn collect( + node: &vectorcraft_doc::Node, + paths: &mut Vec, + elements: &mut usize, + limits: Limits, + clipped: &mut bool, + omitted: &mut bool, +) -> Result<(), Error> { if !node.visible { return Ok(()); } if let Some(data) = node.path_data() { + if matches!(node.kind, vectorcraft_doc::NodeKind::Path { clipping: true, .. }) { + return Ok(()); + } let path = data.to_bezpath(); if !path.elements().is_empty() { *elements = elements.saturating_add(path.elements().len()); @@ -72,9 +94,16 @@ fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mu paths.push(path); } } else if let Some(children) = node.children() { - for child in children { - collect(child, paths, elements, limits)?; + let is_clip = matches!(node.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. } | vectorcraft_doc::NodeKind::Layer { clip: true, .. }); + *clipped |= is_clip; + for (index, child) in children.iter().enumerate() { + if is_clip && index == 0 { + continue; + } + collect(child, paths, elements, limits, clipped, omitted)?; } + } else { + *omitted = true; } Ok(()) } diff --git a/crates/pdf/src/paths.rs b/crates/pdf/src/paths.rs index bb51e2a0f..9f7126013 100644 --- a/crates/pdf/src/paths.rs +++ b/crates/pdf/src/paths.rs @@ -8,6 +8,10 @@ use hayro_interpret::{ BlendMode, ClipPath, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, }; use kurbo::{Affine, BezPath, Rect}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; /// Limits for vector path extraction. They bound the output model, not interpreter peak memory. #[derive(Debug, Clone, Copy)] @@ -61,14 +65,31 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::Pages { limit: limits.max_pages }); } let cache = InterpreterCache::new(); + let truncated = Arc::new(AtomicBool::new(false)); + let truncation_flag = truncated.clone(); + let settings = InterpreterSettings { + warning_sink: Arc::new(move |warning| { + if matches!(warning, hayro_interpret::InterpreterWarning::ContentTruncated) { + truncation_flag.store(true, Ordering::Relaxed); + } + }), + ..InterpreterSettings::default() + }; let mut pages = Vec::with_capacity(pdf.pages().len()); let mut total_paths = 0usize; let mut total_elements = 0usize; for page in pdf.pages().iter() { let crop_box = page.intersected_crop_box().to_kurbo(); - let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), InterpreterSettings::default()); - let mut device = Collector::default(); + let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), settings.clone()); + let mut device = Collector { + max_paths: limits.max_paths.saturating_sub(total_paths), + max_elements: limits.max_path_elements.saturating_sub(total_elements), + ..Collector::default() + }; crate::read::interpret_page(page, &mut context, &mut device)?; + if device.exceeded { + return Err(Error::OutputLimit); + } total_paths = total_paths.saturating_add(device.paths.len()); total_elements = device.paths.iter().fold(total_elements, |n, path| n.saturating_add(path.elements().len())); if total_paths > limits.max_paths || total_elements > limits.max_path_elements { @@ -92,6 +113,9 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { if pages.iter().any(|page| page.clips_seen > 0) { warnings.push("clip paths are not applied to extracted geometry".into()); } + if truncated.load(Ordering::Relaxed) { + warnings.push("PDF content was truncated by the interpreter".into()); + } Ok(Imported { pages, warnings }) } @@ -101,16 +125,26 @@ struct Collector { omitted_text: usize, omitted_images: usize, clips_seen: usize, + max_paths: usize, + max_elements: usize, + elements: usize, + exceeded: bool, } impl Device<'_> for Collector { fn set_soft_mask(&mut self, _: Option>) {} fn set_blend_mode(&mut self, _: BlendMode) {} fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) { - let path = transform * path.clone(); - if !path.is_empty() { - self.paths.push(path); + if path.is_empty() { + return; } + let count = path.elements().len(); + if self.paths.len() >= self.max_paths || self.elements.saturating_add(count) > self.max_elements { + self.exceeded = true; + return; + } + self.elements = self.elements.saturating_add(count); + self.paths.push(transform * path.clone()); } fn push_clip_path(&mut self, _: &ClipPath) { self.clips_seen = self.clips_seen.saturating_add(1); diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index 664ce8f01..b73c8c1bf 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -107,7 +107,11 @@ impl Scene { } fn write_attributes<'a>(mut tag: craft_xml::Tag<'a>, attributes: &'a [Attribute]) -> craft_xml::Tag<'a> { - for attribute in attributes { + // SVG consumers disagree about duplicate attributes; make the last supplied value win. + for (index, attribute) in attributes.iter().enumerate() { + if attributes[index + 1..].iter().any(|later| later.name == attribute.name) { + continue; + } tag = match &attribute.value { Value::Text(value) => tag.attr(&attribute.name, value), Value::Number(value) => tag.attr(&attribute.name, num(*value, 3)), @@ -136,8 +140,9 @@ fn write_elements(writer: &mut SvgWriter, elements: &[Element], depth: usize) { writer.close(); } Element::Path { attributes, path } => { - let attributes = attributes.clone(); - write_attributes(writer.tag("path").attr("d", path_data(path)), &attributes).empty(); + let mut attrs = vec![Attribute::text("d", path_data(path))]; + attrs.extend(attributes.iter().cloned()); + write_attributes(writer.tag("path"), &attrs).empty(); } Element::Rect { attributes, x, y, width, height } => { let mut attrs = vec![ From 7fbe3837bcfda6d85fb193402922e8f3cde8b94d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:39:40 -0500 Subject: [PATCH 036/118] Fix EPS path bounds variable --- crates/eps/src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs index ad1f23793..c4adc0d1b 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -47,7 +47,7 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; let section = crate::sections(bytes).map_err(|e| Error::Parse(e.to_string()))?; let source_box = crate::Comments::read(section.postscript).page_box().unwrap_or([0.0, 0.0, 612.0, 792.0]); - let [llx, _lly, urx, ury] = source_box; + let [llx, lly, urx, ury] = source_box; let page_bounds = Rect::new(llx, lly, urx, ury); let mut paths = Vec::new(); let mut total_elements = 0usize; From 27d3b4acf5ac48d62db2e9390e08e5a4e2daea7b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:08:30 -0500 Subject: [PATCH 037/118] Keep StencilCraft v1 fixtures with shared document crate --- crates/stencilcraft-document/src/lib.rs | 9 +- .../tests/fixtures/poster.stencilcraft | 98 ++++++++++++++++ .../tests/fixtures/starter.stencilcraft | 111 ++++++++++++++++++ .../tests/fixtures/stencil-frame.stencilcraft | 86 ++++++++++++++ 4 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 crates/stencilcraft-document/tests/fixtures/poster.stencilcraft create mode 100644 crates/stencilcraft-document/tests/fixtures/starter.stencilcraft create mode 100644 crates/stencilcraft-document/tests/fixtures/stencil-frame.stencilcraft diff --git a/crates/stencilcraft-document/src/lib.rs b/crates/stencilcraft-document/src/lib.rs index a966536c5..bca33fb8d 100644 --- a/crates/stencilcraft-document/src/lib.rs +++ b/crates/stencilcraft-document/src/lib.rs @@ -772,9 +772,12 @@ mod tests { #[test] fn version_1_files_without_canvas_fields_still_load() { - for name in ["starter", "poster", "stencil-frame"] { - let path = format!("{}/../../examples/{name}.stencilcraft", env!("CARGO_MANIFEST_DIR")); - let doc = load_from(&path).unwrap(); + for json in [ + include_str!("../tests/fixtures/starter.stencilcraft"), + include_str!("../tests/fixtures/poster.stencilcraft"), + include_str!("../tests/fixtures/stencil-frame.stencilcraft"), + ] { + let doc = Document::load_json(json).unwrap(); assert!(doc.pages.is_empty() && doc.groups.is_empty() && doc.guides.is_empty()); assert_eq!(doc.grid, Grid::default()); assert!(doc.layers.iter().all(|l| !l.template && !l.locked)); diff --git a/crates/stencilcraft-document/tests/fixtures/poster.stencilcraft b/crates/stencilcraft-document/tests/fixtures/poster.stencilcraft new file mode 100644 index 000000000..4de690925 --- /dev/null +++ b/crates/stencilcraft-document/tests/fixtures/poster.stencilcraft @@ -0,0 +1,98 @@ +{ + "board": { + "height_mm": 400.0, + "width_mm": 500.0 + }, + "kerf": { + "apply_to_exports": false, + "width_mm": 0.0 + }, + "layers": [ + { + "kind": "cut", + "name": "cut", + "shapes": [ + { + "id": 1, + "path": [ + { + "MoveTo": { + "x": 10.0, + "y": 20.0 + } + }, + { + "LineTo": { + "x": 70.0, + "y": 20.0 + } + }, + { + "LineTo": { + "x": 70.0, + "y": 80.0 + } + }, + { + "LineTo": { + "x": 10.0, + "y": 80.0 + } + }, + "ClosePath" + ], + "style": "cut" + }, + { + "id": 2, + "path": [ + { + "MoveTo": { + "x": 10.0, + "y": 390.0 + } + }, + { + "LineTo": { + "x": 490.0, + "y": 10.0 + } + } + ], + "style": "cut" + } + ], + "visible": true + }, + { + "kind": "engrave", + "name": "engrave", + "shapes": [ + { + "id": 3, + "path": [ + { + "MoveTo": { + "x": 100.0, + "y": 200.0 + } + }, + { + "LineTo": { + "x": 400.0, + "y": 200.0 + } + } + ], + "style": "engrave" + } + ], + "visible": true + } + ], + "next-shape-id": 4, + "stencilcraft": { + "name": "stencilcraft", + "version": 1 + } +} diff --git a/crates/stencilcraft-document/tests/fixtures/starter.stencilcraft b/crates/stencilcraft-document/tests/fixtures/starter.stencilcraft new file mode 100644 index 000000000..2a18fbb11 --- /dev/null +++ b/crates/stencilcraft-document/tests/fixtures/starter.stencilcraft @@ -0,0 +1,111 @@ +{ + "board": { + "height_mm": 150.0, + "width_mm": 100.0 + }, + "kerf": { + "apply_to_exports": false, + "width_mm": 0.0 + }, + "layers": [ + { + "kind": "cut", + "name": "cut", + "shapes": [ + { + "id": 1, + "path": [ + { + "MoveTo": { + "x": 10.0, + "y": 10.0 + } + }, + { + "LineTo": { + "x": 60.0, + "y": 10.0 + } + }, + { + "LineTo": { + "x": 60.0, + "y": 60.0 + } + }, + { + "LineTo": { + "x": 10.0, + "y": 60.0 + } + }, + "ClosePath" + ], + "style": "cut" + }, + { + "id": 2, + "path": [ + { + "MoveTo": { + "x": 70.0, + "y": 20.0 + } + }, + { + "LineTo": { + "x": 85.0, + "y": 20.0 + } + }, + { + "LineTo": { + "x": 85.0, + "y": 60.0 + } + }, + { + "LineTo": { + "x": 70.0, + "y": 60.0 + } + }, + "ClosePath" + ], + "style": "cut" + } + ], + "visible": true + }, + { + "kind": "engrave", + "name": "engrave", + "shapes": [ + { + "id": 3, + "path": [ + { + "MoveTo": { + "x": 10.0, + "y": 90.0 + } + }, + { + "LineTo": { + "x": 90.0, + "y": 90.0 + } + } + ], + "style": "engrave" + } + ], + "visible": true + } + ], + "next-shape-id": 4, + "stencilcraft": { + "name": "stencilcraft", + "version": 1 + } +} \ No newline at end of file diff --git a/crates/stencilcraft-document/tests/fixtures/stencil-frame.stencilcraft b/crates/stencilcraft-document/tests/fixtures/stencil-frame.stencilcraft new file mode 100644 index 000000000..3e0a77ed8 --- /dev/null +++ b/crates/stencilcraft-document/tests/fixtures/stencil-frame.stencilcraft @@ -0,0 +1,86 @@ +{ + "board": { + "height_mm": 150.0, + "width_mm": 120.0 + }, + "kerf": { + "apply_to_exports": true, + "width_mm": 0.2 + }, + "layers": [ + { + "kind": "cut", + "name": "cut", + "shapes": [ + { + "id": 1, + "path": [ + { + "MoveTo": { + "x": 0.0, + "y": 0.0 + } + }, + { + "LineTo": { + "x": 100.0, + "y": 0.0 + } + }, + { + "LineTo": { + "x": 100.0, + "y": 100.0 + } + }, + { + "LineTo": { + "x": 0.0, + "y": 100.0 + } + }, + "ClosePath" + ], + "style": "cut" + }, + { + "id": 2, + "path": [ + { + "MoveTo": { + "x": 30.0, + "y": 30.0 + } + }, + { + "LineTo": { + "x": 70.0, + "y": 30.0 + } + }, + { + "LineTo": { + "x": 70.0, + "y": 70.0 + } + }, + { + "LineTo": { + "x": 30.0, + "y": 70.0 + } + }, + "ClosePath" + ], + "style": "cut" + } + ], + "visible": true + } + ], + "next-shape-id": 3, + "stencilcraft": { + "name": "stencilcraft", + "version": 1 + } +} From 10e7f7763a5bae00a9f30929dbbc9aaf084b57a0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:13:55 -0500 Subject: [PATCH 038/118] Test typed StencilCraft document readers --- crates/stencilcraft-format/src/lib.rs | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/stencilcraft-format/src/lib.rs b/crates/stencilcraft-format/src/lib.rs index 9a0df0093..1ff64af59 100644 --- a/crates/stencilcraft-format/src/lib.rs +++ b/crates/stencilcraft-format/src/lib.rs @@ -364,6 +364,7 @@ pub enum Error { mod tests { use std::io::{self, Cursor, Read}; + use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::{DocumentFields, Error, FORMAT_VERSION, File}; @@ -469,4 +470,43 @@ mod tests { assert!(matches!(File::read("{"), Err(Error::Json(_)))); assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":1}}"#), Err(Error::Document { .. }))); } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct TypedBoard { + width_mm: f64, + height_mm: f64, + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct TypedFields { + board: TypedBoard, + #[serde(default, rename = "board-setup", skip_serializing_if = "Option::is_none")] + board_setup: Option, + } + + fn typed_fields() -> TypedFields { + TypedFields { board: TypedBoard { width_mm: 100.0, height_mm: 150.0 }, board_setup: None } + } + + #[test] + fn typed_readers_round_trip_typed_fields_and_preserve_header_errors() -> Result<(), Error> { + let file = File::new(typed_fields()); + let text = file.to_json_pretty()?; + assert!(!text.contains("board-setup")); + assert_eq!(File::read_typed(&text)?, file); + assert_eq!(File::read_typed_from(Cursor::new(text.as_bytes()))?, file); + + let malformed = r#"{"stencilcraft":{"name":"stencilcraft","version":1},"board":"wrong"}"#; + assert!(matches!(File::::read_typed(malformed), Err(Error::Document { .. }))); + assert!(matches!(File::::read_typed_from(Cursor::new(malformed)), Err(Error::Document { .. }))); + + let wrong_header = r#"{"stencilcraft":{"name":"other","version":1},"board":{}}"#; + assert!(matches!(File::::read_typed(wrong_header), Err(Error::NotStencilCraft))); + assert!(matches!(File::::read_typed_from(Cursor::new(wrong_header)), Err(Error::NotStencilCraft))); + + let future_version = r#"{"stencilcraft":{"name":"stencilcraft","version":2},"board":{}}"#; + assert!(matches!(File::::read_typed(future_version), Err(Error::Version { found: 2, supported: 1 }))); + assert!(matches!(File::::read_typed_from(Cursor::new(future_version)), Err(Error::Version { found: 2, supported: 1 }))); + Ok(()) + } } From b32e55a956e6f25517468cc2563c79bc59e16f1b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 09:21:57 +0000 Subject: [PATCH 039/118] Preserve session repairs and frame grid boundaries --- crates/scraft-format/src/session.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/scraft-format/src/session.rs b/crates/scraft-format/src/session.rs index 5f73f940e..021cab8bc 100644 --- a/crates/scraft-format/src/session.rs +++ b/crates/scraft-format/src/session.rs @@ -348,6 +348,7 @@ impl AutoName { pub struct Session { pub format_version: u32, pub name: String, + #[serde(deserialize_with = "sample_rate_or_default")] pub sample_rate: SampleRate, pub bit_depth: BitDepthSetting, pub frame_rate: FrameRate, @@ -384,6 +385,17 @@ pub struct Session { pub folder: Option, } +/// Older SoundCraft builds repaired an in-range but invalid sample rate after reading a session. +/// `craft-time` validates `SampleRate` during serde, so keep that file compatibility at this +/// domain boundary while retaining strict serde validation for direct `SampleRate` use. +fn sample_rate_or_default<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let hz = ::deserialize(deserializer)?; + Ok(SampleRate::new(hz).unwrap_or_default()) +} + impl Default for Session { fn default() -> Self { Session::new("Untitled", SampleRate::HZ_48000) From 2f281a419c90282348306d2ba019ce8f0cc9f3f0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 09:22:49 +0000 Subject: [PATCH 040/118] Align frame grid lines with typed position rounding --- crates/time/src/soundcraft/grid.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/time/src/soundcraft/grid.rs b/crates/time/src/soundcraft/grid.rs index 2d40a454b..9055340ba 100644 --- a/crates/time/src/soundcraft/grid.rs +++ b/crates/time/src/soundcraft/grid.rs @@ -219,15 +219,15 @@ impl FeetFramesValue { } } - /// Apply the counter origin in frames before rounding once to the nearest sample. + /// Apply the counter origin in frames before rounding once to the first sample at the frame + /// boundary. This matches `FrameRate::samples_at_frame` and typed Feet+Frames positions. fn sample_line(self, index: i128, sr: SampleRate, rate: FrameRate, origin: i64) -> Samples { let (num, den) = self.frame_ratio(); let (rate_num, rate_den) = rate.rational(); let frames = index * num - i128::from(origin) * den; let samples = frames * i128::from(sr.hz().max(1)) * i128::from(rate_den); let divisor = den * i128::from(rate_num); - let magnitude = (samples.abs() + divisor / 2) / divisor; - let rounded = if samples < 0 { -magnitude } else { magnitude }; + let rounded = samples.div_euclid(divisor) + i128::from(samples.rem_euclid(divisor) != 0); i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }) } @@ -588,6 +588,7 @@ fn step_enum(values: &[T], value: T, larger: bool) -> Optio #[cfg(test)] mod tests { use super::*; + use crate::soundcraft::TimeFormat; /// Frame origins are subtracted before rounding whole frames, quarter frames and subframes. #[test] @@ -601,6 +602,24 @@ mod tests { assert_eq!(FeetFramesValue::SubFrame.lines(49_665, 49_667, sr, rate, 1, 4), [49_666]); } + /// Grid lines use the same ceil-rounded sample boundary as typed positions at fractional rates. + #[test] + fn feet_frames_grid_lines_match_typed_positions_at_fractional_rates() { + let sr = SampleRate::HZ_48000; + let map = TempoMap::default(); + for rate in [FrameRate::Fps23_976, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + for origin in [-17, -1, 1, 17] { + for feet in [-64, -2, 0, 1, 2, 4, 7, 12, 128] { + let text = format!("{feet}+00"); + let at = crate::soundcraft::format::parse_position(&text, TimeFormat::FeetFrames, sr, &map, rate, 0, origin).unwrap(); + for value in FeetFramesValue::ALL { + assert_eq!(value.line_at_or_before(at, sr, rate, origin), at, "{rate:?} {value:?} origin {origin}, {text}"); + } + } + } + } + } + /// Dense subframes retain distinct sample lines and remain bounded with extreme signed origins. #[test] fn feet_frames_subframes_handle_dense_steps_and_extreme_origins() { From 81b88332b59f16db5e64e59e4116cdde1c6ba0f7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:34:44 -0500 Subject: [PATCH 041/118] Share library and recent files JSON models --- crates/stencilcraft-document/src/lib.rs | 2 + crates/stencilcraft-document/src/metadata.rs | 85 ++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 crates/stencilcraft-document/src/metadata.rs diff --git a/crates/stencilcraft-document/src/lib.rs b/crates/stencilcraft-document/src/lib.rs index bca33fb8d..6e04be5b5 100644 --- a/crates/stencilcraft-document/src/lib.rs +++ b/crates/stencilcraft-document/src/lib.rs @@ -6,6 +6,7 @@ pub mod canvas; pub mod geom; +pub mod metadata; use crate::geom::{GeomError, bounds_of_shape, check_finite_mm}; pub use canvas::{Grid, Group, GroupChild, Guide, GuideAxis, Metadata, Orientation, Page, PagePreset, PageSetup, SnapSettings}; @@ -13,6 +14,7 @@ use kurbo::BezPath; use serde::{Deserialize, Serialize}; pub mod production; +pub use metadata::{LibraryIndex, LibraryTrashEntry, MAX_RECENT_FILES, RecentFiles}; pub use production::{CombineOp, GroupKind, Operation, SendAssign, ShapeProduction, SourceGroup}; /// Native format identity and supported version, defined by the shared wire-format crate. diff --git a/crates/stencilcraft-document/src/metadata.rs b/crates/stencilcraft-document/src/metadata.rs new file mode 100644 index 000000000..04fb24d83 --- /dev/null +++ b/crates/stencilcraft-document/src/metadata.rs @@ -0,0 +1,85 @@ +//! Small JSON records used by StencilCraft's local library and recent-files list. +//! +//! These records are separate from a document, but their on-disk field names, defaults and +//! JSON encoding are part of StencilCraft's supported persisted data. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::DocError; + +/// One item in the local library recycle bin. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LibraryTrashEntry { + /// File name inside the bin. + pub name: String, + /// Where it was, for restoring. + pub original: String, +} + +/// The `.library.json` record stored at the root of a StencilCraft library. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct LibraryIndex { + #[serde(default)] + pub favourites: Vec, + #[serde(default)] + pub trash: Vec, +} + +impl LibraryIndex { + /// Parse the library index, applying defaults for fields absent in older indexes. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Serialize the library index using the canonical pretty JSON representation. + pub fn to_json_pretty(&self) -> Result { + serde_json::to_string_pretty(self) + } +} + +/// Maximum number of paths retained in the recent-files record. +pub const MAX_RECENT_FILES: usize = 10; + +/// Most recently used document paths, newest first, without duplicates. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct RecentFiles { + #[serde(default)] + pub paths: Vec, +} + +impl RecentFiles { + /// Parse recent files from their persisted JSON representation. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Serialize recent files using the canonical pretty JSON representation. + pub fn to_json_pretty(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Moves `path` to the front, keeping at most [`MAX_RECENT_FILES`]. + pub fn push(&mut self, path: &str) { + self.paths.retain(|p| p != path); + self.paths.insert(0, path.to_string()); + self.paths.truncate(MAX_RECENT_FILES); + } + + pub fn remove(&mut self, path: &str) { + self.paths.retain(|p| p != path); + } + + /// Reads the list; a missing or damaged file gives an empty list. + pub fn load(file: &Path) -> Self { + let mut list = std::fs::read_to_string(file).ok().and_then(|text| Self::from_json(&text).ok()).unwrap_or_default(); + list.paths.truncate(MAX_RECENT_FILES); + list + } + + pub fn save(&self, file: &Path) -> Result<(), DocError> { + let json = self.to_json_pretty().map_err(|e| DocError::Structure { path: file.display().to_string(), message: e.to_string() })?; + craft_atomic::atomic_write(file, json.as_bytes()).map_err(|source| DocError::Io { path: file.display().to_string(), source }) + } +} From 65b2a68df0b46d987b44dd2a51f1d706c1a7c372 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:12:58 -0500 Subject: [PATCH 042/118] Add shared DesignCraft native format codec --- Cargo.lock | 11 ++ Cargo.toml | 1 + crates/designcraft-format/Cargo.toml | 18 ++ crates/designcraft-format/src/lib.rs | 247 +++++++++++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 crates/designcraft-format/Cargo.toml create mode 100644 crates/designcraft-format/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b24081da4..75a575b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1286,6 +1286,17 @@ dependencies = [ "proptest", ] +[[package]] +name = "craft-designcraft-format" +version = "0.1.0" +dependencies = [ + "craft-document-bundle", + "craft-eps", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-dialogs" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b86ae2fa8..7c38b6f92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ craft-dialogs = { path = "crates/dialogs" } craft-doc-binary = { path = "crates/doc-binary" } craft-docx = { path = "crates/docx" } craft-document-bundle = { path = "crates/document-bundle" } +craft-designcraft-format = { path = "crates/designcraft-format" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } diff --git a/crates/designcraft-format/Cargo.toml b/crates/designcraft-format/Cargo.toml new file mode 100644 index 000000000..19557b7ae --- /dev/null +++ b/crates/designcraft-format/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "craft-designcraft-format" +description = "Versioned DesignCraft document JSON codec and legacy migrations over craft-document-bundle" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-document-bundle = { workspace = true } +craft-eps = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/designcraft-format/src/lib.rs b/crates/designcraft-format/src/lib.rs new file mode 100644 index 000000000..b497aa4cd --- /dev/null +++ b/crates/designcraft-format/src/lib.rs @@ -0,0 +1,247 @@ +//! Versioned JSON and bundle codec for DesignCraft's native document format. +//! +//! The application owns its document model. This crate owns the on-disk schema version, +//! serialization, legacy JSON upgrades, version inference and the shared ZIP envelope. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; + +use craft_document_bundle::{Asset, Spec}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +pub const MIME: &str = "application/vnd.designcraft+zip"; +pub const VERSION: u32 = 4; +pub const EXTENSION: &str = "designcraft"; +pub const PREVIEW: &str = "preview.png"; +pub const MAX_PREVIEW_BYTES: u64 = 4 << 20; + +const BUNDLE: Spec<'static> = Spec { + mimetype_entry: "mimetype", + mimetype: MIME, + metadata_entry: "meta.json", + document_entry: "document.json", + preview_entry: PREVIEW, + assets_directory: "assets", + format_name: "designcraft", + legacy_assets_field: Some("assetData"), +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not a DesignCraft document: {0}")] + NotOurs(String), + #[error("document is from a newer DesignCraft (format {0}); please update")] + TooNew(u64), + #[error("{0}")] + Io(String), + #[error("document cannot be saved: {0}")] + Unsaveable(String), +} + +impl From for Error { + fn from(error: craft_document_bundle::Error) -> Self { + match error { + craft_document_bundle::Error::TooNew(version) => Error::TooNew(version), + craft_document_bundle::Error::NotBundle(message) => Error::NotOurs(message), + craft_document_bundle::Error::Read(message) | craft_document_bundle::Error::Write(message) => Error::Io(message), + } + } +} + +/// A decoded native document and the separately stored embedded assets. +#[derive(Clone, Debug, PartialEq)] +pub struct Loaded { + pub document: T, + pub assets: Vec<(String, Vec)>, +} + +/// The reader capability required by `document`, inferred from its neutral JSON schema and +/// embedded assets. Traversal beyond 64 levels conservatively requires version 4. +pub fn required_version(document: &Value, assets: &[Asset<'_>]) -> u32 { + if has_version_four_feature(document) { + return 4; + } + if assets.iter().any(|asset| craft_eps::is_eps(asset.data)) { 3 } else { 2 } +} + +/// Serialize a document and its external assets as a `.designcraft` ZIP bundle. +pub fn save(document: &T, generator: &str, preview: Option<&[u8]>, assets: &[Asset<'_>]) -> Result, Error> { + let value = serde_json::to_value(document).map_err(|error| Error::Io(error.to_string()))?; + let version = required_version(&value, assets); + let json = serde_json::to_vec_pretty(&value).map_err(|error| Error::Io(error.to_string()))?; + craft_document_bundle::write(&json, version, generator, preview, assets, BUNDLE).map_err(Into::into) +} + +/// Read a `.designcraft` ZIP or legacy single-file JSON, applying all versioned schema migrations +/// before deserializing the caller's model. +pub fn load(bytes: &[u8]) -> Result, Error> { + let mut package = craft_document_bundle::read(bytes, VERSION, BUNDLE)?; + upgrade_legacy_json(&mut package.document, package.version); + let document = serde_json::from_value(package.document).map_err(|error| Error::NotOurs(error.to_string()))?; + Ok(Loaded { document, assets: package.assets }) +} + +/// Whether `bytes` are a native DesignCraft ZIP package. +pub fn is_designcraft(bytes: &[u8]) -> bool { + craft_document_bundle::has_document(bytes, BUNDLE) +} + +/// Whether `prefix` begins with this format's stored mimetype entry. +pub fn starts_designcraft(prefix: &[u8]) -> bool { + craft_document_bundle::starts_with_mimetype(prefix, BUNDLE) +} + +/// Read the first-page PNG preview from a ZIP package, with a fixed 4 MiB cap. +pub fn read_preview(reader: R) -> Option> { + craft_document_bundle::read_preview_from(reader, PREVIEW) +} + +fn has_version_four_feature(document: &Value) -> bool { + let mut stack = vec![(document, 0usize)]; + let mut visited = 0usize; + while let Some((value, depth)) = stack.pop() { + if depth > 64 || visited >= 1_000_000 { + return true; + } + visited += 1; + match value { + Value::Object(object) => { + if object.get("layerOptions").is_some_and(|options| !options.is_null()) + || (object.get("pdfLayerOverride").and_then(Value::as_bool) == Some(true) + && object.get("pdfHiddenLayers").and_then(Value::as_array).is_none_or(Vec::is_empty)) + { + return true; + } + stack.extend(object.values().map(|child| (child, depth + 1))); + } + Value::Array(children) => stack.extend(children.iter().map(|child| (child, depth + 1))), + _ => {} + } + } + false +} + +fn upgrade_legacy_json(document: &mut Value, from: u64) { + let mut vertical = HashSet::new(); + vertical_frames(document, &mut vertical, 0); + if let Some(stories) = document.get_mut("stories").and_then(Value::as_object_mut) { + for story in stories.values_mut() { + let first = story.get("frames").and_then(|frames| frames.get(0)).and_then(Value::as_u64); + if let (Some(frame), Some(story)) = (first, story.as_object_mut()) + && vertical.contains(&frame) + && !story.contains_key("vertical") + { + story.insert("vertical".into(), Value::Bool(true)); + } + } + } + if from < 2 { + pin_paragraph_defaults(document); + } +} + +fn vertical_frames(value: &Value, out: &mut HashSet, depth: usize) { + if depth > 256 { + return; + } + match value { + Value::Object(object) => { + if let (Some(id), Some(content)) = (object.get("id").and_then(Value::as_u64), object.get("content")) + && content.get("type").and_then(Value::as_str) == Some("text") + && content.get("options").and_then(|options| options.get("vertical")).and_then(Value::as_bool) == Some(true) + { + out.insert(id); + } + for child in object.values() { + vertical_frames(child, out, depth + 1); + } + } + Value::Array(children) => { + for child in children { + vertical_frames(child, out, depth + 1); + } + } + _ => {} + } +} + +fn pin_paragraph_defaults(document: &mut Value) { + const MAX_STYLE_DEPTH: usize = 64; + let styles = document.pointer("/styles/paragraph").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut by_name: HashMap, bool, bool)> = HashMap::new(); + for style in &styles { + let Some(name) = style.get("name").and_then(Value::as_str) else { continue }; + let based_on = style.get("basedOn").and_then(Value::as_str).map(str::to_string); + let attrs = style.get("para"); + let missing_keep = attrs.and_then(|attrs| attrs.get("keepAllLines")).is_none(); + let missing_drop_cap = attrs.and_then(|attrs| attrs.get("dropCapAlignLeft")).is_none(); + by_name.entry(name.to_string()).or_insert((based_on, missing_keep, missing_drop_cap)); + } + + let mut roots = HashSet::new(); + for name in by_name.keys() { + let mut chain = Vec::new(); + let mut seen = HashSet::new(); + let mut current = Some(name.as_str()); + while let Some(style_name) = current { + let Some((based_on, missing_keep, missing_drop_cap)) = by_name.get(style_name) else { break }; + if chain.len() >= MAX_STYLE_DEPTH || !seen.insert(style_name.to_string()) { + break; + } + chain.push((style_name, *missing_keep, *missing_drop_cap)); + current = based_on.as_deref(); + } + if !chain.is_empty() + && chain.iter().all(|(_, keep, drop_cap)| *keep && *drop_cap) + && let Some((root, _, _)) = chain.last() + { + roots.insert((*root).to_string()); + } + } + if let Some(styles) = document.pointer_mut("/styles/paragraph").and_then(Value::as_array_mut) { + for style in styles { + if let Some(style) = style.as_object_mut() + && style.get("name").and_then(Value::as_str).is_some_and(|name| roots.contains(name)) + { + let attrs = style.entry("para").or_insert_with(|| Value::Object(Default::default())); + if let Some(attrs) = attrs.as_object_mut() { + attrs.entry("keepAllLines").or_insert(Value::Bool(true)); + attrs.entry("dropCapAlignLeft").or_insert(Value::Bool(false)); + } + } + } + } + let known_styles: HashSet = by_name.into_keys().collect(); + pin_unstyled_paragraphs(document, &known_styles, 0); +} + +fn pin_unstyled_paragraphs(value: &mut Value, known_styles: &HashSet, depth: usize) { + if depth > 256 { + return; + } + match value { + Value::Object(object) => { + if let Some(style) = object.get("style").and_then(Value::as_str) + && !known_styles.contains(style) + { + let attrs = object.entry("para").or_insert_with(|| Value::Object(Default::default())); + if let Some(attrs) = attrs.as_object_mut() { + attrs.entry("keepAllLines").or_insert(Value::Bool(true)); + attrs.entry("dropCapAlignLeft").or_insert(Value::Bool(false)); + } + } + for child in object.values_mut() { + pin_unstyled_paragraphs(child, known_styles, depth + 1); + } + } + Value::Array(children) => { + for child in children { + pin_unstyled_paragraphs(child, known_styles, depth + 1); + } + } + _ => {} + } +} From 19692b6190465671e41967c75d0cd4c439d0c84c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:15:16 -0500 Subject: [PATCH 043/118] Expose shared native bundle asset type --- crates/designcraft-format/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/designcraft-format/src/lib.rs b/crates/designcraft-format/src/lib.rs index b497aa4cd..21633a464 100644 --- a/crates/designcraft-format/src/lib.rs +++ b/crates/designcraft-format/src/lib.rs @@ -8,7 +8,8 @@ use std::collections::{HashMap, HashSet}; use std::io::{Read, Seek}; -use craft_document_bundle::{Asset, Spec}; +pub use craft_document_bundle::Asset; +use craft_document_bundle::Spec; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; From c96926f5bf450f4c176160999842a71de472dd12 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 10:24:48 +0000 Subject: [PATCH 044/118] share published FigCraft library manifest schema --- crates/figcraft-format/src/lib.rs | 1 + crates/figcraft-format/src/library.rs | 107 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 crates/figcraft-format/src/library.rs diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 3dc8e3a32..ccbab57ed 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -1,6 +1,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] //! The neutral JSON header and version contract for FigCraft's native project format. //! App-specific document fields remain a generic flattened payload. +pub mod library; use serde::de::{IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; diff --git a/crates/figcraft-format/src/library.rs b/crates/figcraft-format/src/library.rs new file mode 100644 index 000000000..fe42dc7a4 --- /dev/null +++ b/crates/figcraft-format/src/library.rs @@ -0,0 +1,107 @@ +//! Wire schema and member limits for published libraries embedded in a native project archive. +use serde::{Deserialize, Serialize}; + +pub const MANIFEST: &str = "library/manifest.json"; +pub const DOCUMENT: &str = "library/document.json"; +pub const IMAGES_PREFIX: &str = "library/images/"; +pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024; +pub const MAX_ID_CHARS: usize = 64; +pub const MAX_NAME_CHARS: usize = 200; +pub const MAX_DESCRIPTION_CHARS: usize = 2000; + +/// Native library manifest, independent of the consumer's document model. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Manifest { + pub id: String, + pub name: String, + pub version: u64, + pub description: String, + pub time: u64, + pub components: Vec, + pub styles: Vec